@smbcloud / cli / commits / 6a42121

Add interface modes: headless (default), TUI, and MCP server

Split the CLI's presentation into three explicit interfaces selectable at runtime, with headless plain-text as the new default: - `interface.rs`: an `Interface { Headless, Tui, Mcp }` global mirroring `ci.rs`, resolved once in `main` from new global `--tui` / `--mcp` flags (mutually exclusive). `--ci` is unchanged; MCP implies non-interactive. - Headless is now the default. Read commands (`me`, `project list`/`show`, `project deployments`, delete confirmations) previously always dropped into a full-screen ratatui view; they now render plain text (`ui/plain.rs`) and only enter the TUI under `--tui`. Delete confirmations share a `prompt::confirm_delete` dispatcher (CI-refuse / TUI dialog / inline). - `mcp.rs`: a stdio MCP server built on the official `rmcp` SDK, started by `smb --mcp` instead of one-shot dispatch. Exposes the `me` command as the first tool (calls the library fetch directly, returns JSON, keeps stdout clean for JSON-RPC). Diagnostics go to stderr; logging stays file-only. Verified end-to-end: `--help` lists the flags, clap enforces `--tui`/`--mcp` exclusivity, and the MCP server completes initialize / tools/list / tools/call over stdio. CI gate (fmt, clippy -D warnings, tests) is green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi

Claude committed Jul 17, 2026 at 12:28 UTC 6a42121ca570f273f2a938f409318f92f6d04e3a
16 files changed +593 -33
Cargo.lock
+152
@@ -587,6 +587,12 @@ dependencies = [
587 "syn 2.0.118",
588 ]
589
590 +[[package]]
591 +name = "dyn-clone"
592 +version = "1.0.20"
593 +source = "registry+https://github.com/rust-lang/crates.io-index"
594 +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
595 +
596 [[package]]
597 name = "either"
598 version = "1.16.0"
@@ -669,6 +675,21 @@ dependencies = [
675 "percent-encoding",
676 ]
677
678 +[[package]]
679 +name = "futures"
680 +version = "0.3.32"
681 +source = "registry+https://github.com/rust-lang/crates.io-index"
682 +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
683 +dependencies = [
684 + "futures-channel",
685 + "futures-core",
686 + "futures-executor",
687 + "futures-io",
688 + "futures-sink",
689 + "futures-task",
690 + "futures-util",
691 +]
692 +
693 [[package]]
694 name = "futures-channel"
695 version = "0.3.32"
@@ -685,6 +706,34 @@ version = "0.3.32"
706 source = "registry+https://github.com/rust-lang/crates.io-index"
707 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
708
709 +[[package]]
710 +name = "futures-executor"
711 +version = "0.3.32"
712 +source = "registry+https://github.com/rust-lang/crates.io-index"
713 +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
714 +dependencies = [
715 + "futures-core",
716 + "futures-task",
717 + "futures-util",
718 +]
719 +
720 +[[package]]
721 +name = "futures-io"
722 +version = "0.3.32"
723 +source = "registry+https://github.com/rust-lang/crates.io-index"
724 +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
725 +
726 +[[package]]
727 +name = "futures-macro"
728 +version = "0.3.32"
729 +source = "registry+https://github.com/rust-lang/crates.io-index"
730 +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
731 +dependencies = [
732 + "proc-macro2",
733 + "quote",
734 + "syn 2.0.118",
735 +]
736 +
737 [[package]]
738 name = "futures-sink"
739 version = "0.3.32"
@@ -703,9 +752,13 @@ version = "0.3.32"
752 source = "registry+https://github.com/rust-lang/crates.io-index"
753 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
754 dependencies = [
755 + "futures-channel",
756 "futures-core",
757 + "futures-io",
758 + "futures-macro",
759 "futures-sink",
760 "futures-task",
761 + "memchr",
762 "pin-project-lite",
763 "slab",
764 ]
@@ -1594,6 +1647,12 @@ version = "1.0.15"
1647 source = "registry+https://github.com/rust-lang/crates.io-index"
1648 checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
1649
1650 +[[package]]
1651 +name = "pastey"
1652 +version = "0.2.3"
1653 +source = "registry+https://github.com/rust-lang/crates.io-index"
1654 +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
1655 +
1656 [[package]]
1657 name = "percent-encoding"
1658 version = "2.3.2"
@@ -1965,6 +2024,26 @@ dependencies = [
2024 "thiserror 2.0.18",
2025 ]
2026
2027 +[[package]]
2028 +name = "ref-cast"
2029 +version = "1.0.25"
2030 +source = "registry+https://github.com/rust-lang/crates.io-index"
2031 +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
2032 +dependencies = [
2033 + "ref-cast-impl",
2034 +]
2035 +
2036 +[[package]]
2037 +name = "ref-cast-impl"
2038 +version = "1.0.25"
2039 +source = "registry+https://github.com/rust-lang/crates.io-index"
2040 +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
2041 +dependencies = [
2042 + "proc-macro2",
2043 + "quote",
2044 + "syn 2.0.118",
2045 +]
2046 +
2047 [[package]]
2048 name = "regex"
2049 version = "1.13.0"
@@ -2047,6 +2126,41 @@ dependencies = [
2126 "windows-sys 0.52.0",
2127 ]
2128
2129 +[[package]]
2130 +name = "rmcp"
2131 +version = "2.2.0"
2132 +source = "registry+https://github.com/rust-lang/crates.io-index"
2133 +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
2134 +dependencies = [
2135 + "async-trait",
2136 + "base64",
2137 + "chrono",
2138 + "futures",
2139 + "pastey",
2140 + "pin-project-lite",
2141 + "rmcp-macros",
2142 + "schemars",
2143 + "serde",
2144 + "serde_json",
2145 + "thiserror 2.0.18",
2146 + "tokio",
2147 + "tokio-util",
2148 + "tracing",
2149 +]
2150 +
2151 +[[package]]
2152 +name = "rmcp-macros"
2153 +version = "2.2.0"
2154 +source = "registry+https://github.com/rust-lang/crates.io-index"
2155 +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
2156 +dependencies = [
2157 + "darling",
2158 + "proc-macro2",
2159 + "quote",
2160 + "serde_json",
2161 + "syn 2.0.118",
2162 +]
2163 +
2164 [[package]]
2165 name = "roff"
2166 version = "1.1.1"
@@ -2162,6 +2276,32 @@ dependencies = [
2276 "windows-sys 0.61.2",
2277 ]
2278
2279 +[[package]]
2280 +name = "schemars"
2281 +version = "1.2.1"
2282 +source = "registry+https://github.com/rust-lang/crates.io-index"
2283 +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
2284 +dependencies = [
2285 + "chrono",
2286 + "dyn-clone",
2287 + "ref-cast",
2288 + "schemars_derive",
2289 + "serde",
2290 + "serde_json",
2291 +]
2292 +
2293 +[[package]]
2294 +name = "schemars_derive"
2295 +version = "1.2.1"
2296 +source = "registry+https://github.com/rust-lang/crates.io-index"
2297 +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
2298 +dependencies = [
2299 + "proc-macro2",
2300 + "quote",
2301 + "serde_derive_internals",
2302 + "syn 2.0.118",
2303 +]
2304 +
2305 [[package]]
2306 name = "scoped-tls"
2307 version = "1.0.1"
@@ -2238,6 +2378,17 @@ dependencies = [
2378 "syn 2.0.118",
2379 ]
2380
2381 +[[package]]
2382 +name = "serde_derive_internals"
2383 +version = "0.29.1"
2384 +source = "registry+https://github.com/rust-lang/crates.io-index"
2385 +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
2386 +dependencies = [
2387 + "proc-macro2",
2388 + "quote",
2389 + "syn 2.0.118",
2390 +]
2391 +
2392 [[package]]
2393 name = "serde_json"
2394 version = "1.0.150"
@@ -2453,6 +2604,7 @@ dependencies = [
2604 "ratatui",
2605 "regex",
2606 "reqwest",
2607 + "rmcp",
2608 "serde",
2609 "serde_json",
2610 "serde_repr",
Cargo.toml
+1
@@ -23,6 +23,7 @@ openssl = { version = "0.10", features = ["vendored"] }
23 ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
24 regex = "1.11"
25 reqwest = { version = "0.12.18", default-features = false }
26 +rmcp = { version = "2.2.0", features = ["server", "macros", "transport-io", "schemars"] }
27 serde = "1"
28 serde_json = "1.0.82"
29 serde_repr = "0.1"
crates/cli/Cargo.toml
+1
@@ -40,6 +40,7 @@ open = { workspace = true }
40 ratatui = { workspace = true }
41 regex = { workspace = true }
42 reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
43 +rmcp = { workspace = true }
44 serde = { workspace = true, features = ["derive"] }
45 serde_json = { workspace = true }
46 serde_repr = { workspace = true }
crates/cli/src/account/me/mod.rs
+9 -2
@@ -3,7 +3,10 @@ use crate::client;
3 use crate::token::get_smb_token::get_smb_token;
4 use crate::{
5 cli::CommandResult,
6 - ui::{fail_message, fail_symbol, me_view::show_user_tui, succeed_message, succeed_symbol},
6 + interface::is_tui,
7 + ui::{
8 + fail_message, fail_symbol, me_view::show_user_tui, plain, succeed_message, succeed_symbol,
9 + },
10 };
11 use anyhow::{anyhow, Result};
12 use smbcloud_auth::me::me;
@@ -30,7 +33,11 @@ pub async fn process_me(env: Environment) -> Result<CommandResult> {
33 match me(env, client(), &token).await {
34 Ok(user) => {
35 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
33 - show_user_tui(&user).map_err(|e| anyhow!(e))?;
36 + if is_tui() {
37 + show_user_tui(&user).map_err(|e| anyhow!(e))?;
38 + } else {
39 + plain::render_user(&user);
40 + }
41 Ok(CommandResult {
42 spinner: Spinner::new(
43 spinners::Spinners::SimpleDotsScrolling,
crates/cli/src/cli/mod.rs
+10
@@ -34,6 +34,16 @@ pub struct Cli {
34 #[arg(long, global = true, env = "SMB_CI")]
35 pub ci: bool,
36
37 + /// Full-screen TUI mode: render read commands in an interactive ratatui
38 + /// view instead of plain text. Mutually exclusive with --mcp.
39 + #[arg(long, global = true, conflicts_with = "mcp")]
40 + pub tui: bool,
41 +
42 + /// Run as an MCP (Model Context Protocol) server over stdio instead of a
43 + /// one-shot command. Implies non-interactive; the subcommand is ignored.
44 + #[arg(long, global = true)]
45 + pub mcp: bool,
46 +
47 #[command(subcommand)]
48 pub command: Option<Commands>,
49 }
crates/cli/src/interface.rs new
+114
@@ -0,0 +1,114 @@
1 +//! Global interface mode.
2 +//!
3 +//! `smb` presents through one of three interfaces, chosen once at startup and
4 +//! stored here so the deep call tree can consult it without threading a value
5 +//! through every function signature (the same approach as [`crate::ci`]):
6 +//!
7 +//! * [`Interface::Headless`] — the default. Line-based plain-text output; no
8 +//! full-screen takeover. Interactive prompts are still allowed on a TTY
9 +//! unless `--ci` is also set.
10 +//! * [`Interface::Tui`] — full-screen `ratatui` views (`--tui`).
11 +//! * [`Interface::Mcp`] — a Model Context Protocol server over stdio (`--mcp`).
12 +//! Implies non-interactive, structured output.
13 +//!
14 +//! `--tui` and `--mcp` are mutually exclusive.
15 +
16 +use anyhow::{anyhow, Result};
17 +use std::sync::atomic::{AtomicU8, Ordering};
18 +
19 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 +pub enum Interface {
21 + Headless,
22 + Tui,
23 + Mcp,
24 +}
25 +
26 +impl Interface {
27 + fn as_u8(self) -> u8 {
28 + match self {
29 + Interface::Headless => 0,
30 + Interface::Tui => 1,
31 + Interface::Mcp => 2,
32 + }
33 + }
34 +
35 + fn from_u8(value: u8) -> Interface {
36 + match value {
37 + 1 => Interface::Tui,
38 + 2 => Interface::Mcp,
39 + _ => Interface::Headless,
40 + }
41 + }
42 +}
43 +
44 +static INTERFACE: AtomicU8 = AtomicU8::new(0);
45 +
46 +/// Set the active interface. Called once from `main`.
47 +pub fn set_interface(interface: Interface) {
48 + INTERFACE.store(interface.as_u8(), Ordering::Relaxed);
49 +}
50 +
51 +/// The active interface.
52 +pub fn current() -> Interface {
53 + Interface::from_u8(INTERFACE.load(Ordering::Relaxed))
54 +}
55 +
56 +/// Whether the full-screen TUI interface is active.
57 +pub fn is_tui() -> bool {
58 + current() == Interface::Tui
59 +}
60 +
61 +/// Whether the MCP server interface is active.
62 +pub fn is_mcp() -> bool {
63 + current() == Interface::Mcp
64 +}
65 +
66 +/// Whether the default headless interface is active.
67 +pub fn is_headless() -> bool {
68 + current() == Interface::Headless
69 +}
70 +
71 +/// Resolve the interface from the parsed `--tui` / `--mcp` flags. The two flags
72 +/// are mutually exclusive; passing both is an error.
73 +pub fn resolve(tui: bool, mcp: bool) -> Result<Interface> {
74 + match (tui, mcp) {
75 + (true, true) => Err(anyhow!(
76 + "--tui and --mcp cannot be used together: choose one interface."
77 + )),
78 + (false, true) => Ok(Interface::Mcp),
79 + (true, false) => Ok(Interface::Tui),
80 + (false, false) => Ok(Interface::Headless),
81 + }
82 +}
83 +
84 +#[cfg(test)]
85 +mod tests {
86 + use super::*;
87 +
88 + #[test]
89 + fn resolve_defaults_to_headless() {
90 + assert_eq!(resolve(false, false).unwrap(), Interface::Headless);
91 + }
92 +
93 + #[test]
94 + fn resolve_selects_tui_and_mcp() {
95 + assert_eq!(resolve(true, false).unwrap(), Interface::Tui);
96 + assert_eq!(resolve(false, true).unwrap(), Interface::Mcp);
97 + }
98 +
99 + #[test]
100 + fn resolve_rejects_both_flags() {
101 + assert!(resolve(true, true).is_err());
102 + }
103 +
104 + #[test]
105 + fn set_and_read_roundtrip() {
106 + set_interface(Interface::Tui);
107 + assert!(is_tui());
108 + assert!(!is_mcp());
109 + set_interface(Interface::Mcp);
110 + assert!(is_mcp());
111 + set_interface(Interface::Headless);
112 + assert!(is_headless());
113 + }
114 +}
crates/cli/src/lib.rs
+2
@@ -4,7 +4,9 @@ pub mod account;
4 pub mod ci;
5 pub mod cli;
6 pub mod deploy;
7 +pub mod interface;
8 pub mod mail;
9 +pub mod mcp;
10 pub mod project;
11 mod token;
12 pub mod ui;
crates/cli/src/mail/process.rs
+9 -10
@@ -10,10 +10,7 @@ use crate::{
10 },
11 },
12 token::get_smb_token::get_smb_token,
13 - ui::{
14 - confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message,
15 - succeed_symbol,
16 - },
13 + ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
14 };
15 use anyhow::{anyhow, Result};
16 use smbcloud_mail::{
@@ -187,8 +184,10 @@ async fn process_mail_update(
184
185 async fn process_mail_delete(env: Environment, id: String) -> Result<CommandResult> {
186 let mail_app_id = normalize_required("mail app id", id)?;
190 - let confirmed = confirm_delete_tui(&format!("Delete mail app #{mail_app_id}"))
191 - .map_err(|error| anyhow!(error))?;
187 + let confirmed = confirm_delete(
188 + "Mail app deletion confirmation",
189 + &format!("Delete mail app #{mail_app_id}"),
190 + )?;
191
192 if !confirmed {
193 return Ok(done_result("Cancelled."));
@@ -288,10 +287,10 @@ async fn process_mail_inbox_delete(
287 ) -> Result<CommandResult> {
288 let mail_app_id = normalize_required("mail app id", app_id)?;
289 let mail_inbox_id = normalize_required("mail inbox id", id)?;
291 - let confirmed = confirm_delete_tui(&format!(
292 - "Delete mail inbox #{mail_inbox_id} from mail app #{mail_app_id}"
293 - ))
294 - .map_err(|error| anyhow!(error))?;
290 + let confirmed = confirm_delete(
291 + "Mail inbox deletion confirmation",
292 + &format!("Delete mail inbox #{mail_inbox_id} from mail app #{mail_app_id}"),
293 + )?;
294
295 if !confirmed {
296 return Ok(done_result("Cancelled."));
crates/cli/src/main.rs
+48
@@ -78,6 +78,39 @@ async fn main() {
78 let environment = cli.environment;
79 // Resolve CI / non-interactive mode once, before any command can prompt.
80 smbcloud_cli::ci::set_ci(smbcloud_cli::ci::resolve(cli.ci));
81 +
82 + // Resolve the interface (headless / TUI / MCP) once, before dispatch.
83 + let interface = match smbcloud_cli::interface::resolve(cli.tui, cli.mcp) {
84 + Ok(interface) => interface,
85 + Err(e) => {
86 + eprintln!(
87 + "{} {}",
88 + style("✘".to_string()).for_stderr().red(),
89 + style(e).red()
90 + );
91 + std::process::exit(1);
92 + }
93 + };
94 + smbcloud_cli::interface::set_interface(interface);
95 +
96 + // MCP mode runs a stdio server instead of a one-shot command. It implies
97 + // non-interactive, and all diagnostics must go to stderr so the stdout
98 + // JSON-RPC stream stays clean.
99 + if smbcloud_cli::interface::is_mcp() {
100 + smbcloud_cli::ci::set_ci(true);
101 + match run_mcp(cli).await {
102 + Ok(()) => std::process::exit(0),
103 + Err(e) => {
104 + eprintln!(
105 + "{} {}",
106 + style("✘".to_string()).for_stderr().red(),
107 + style(e).red()
108 + );
109 + std::process::exit(1);
110 + }
111 + }
112 + }
113 +
114 match run(cli).await {
115 Ok(result) => {
116 result.stop_and_persist();
@@ -103,6 +136,21 @@ async fn main() {
136 }
137 }
138
139 +/// Run the MCP stdio server. Sets up file logging (never stdout, which is the
140 +/// JSON-RPC channel) and hands off to the server loop. The global internet
141 +/// check is skipped here — the server starts regardless, and individual tool
142 +/// calls surface their own network errors.
143 +async fn run_mcp(cli: Cli) -> Result<()> {
144 + if let Some(user_filter) = &cli.log_level {
145 + let filter = EnvFilter::from_str(user_filter)
146 + .map_err(|_| anyhow!("Invalid log level: {:?}.", cli.log_level))?;
147 + setup_logging(cli.environment, Some(filter))?;
148 + } else {
149 + setup_logging(cli.environment, None)?;
150 + }
151 + smbcloud_cli::mcp::serve(cli.environment).await
152 +}
153 +
154 async fn run(cli: Cli) -> Result<CommandResult> {
155 // println!("Environment: {}", cli.environment);
156
crates/cli/src/mcp.rs new
+88
@@ -0,0 +1,88 @@
1 +//! MCP (Model Context Protocol) server interface.
2 +//!
3 +//! When `smb` is started with `--mcp`, it runs as an MCP server over stdio
4 +//! instead of executing a one-shot command. The server exposes smbCloud
5 +//! operations as MCP tools built on the official `rmcp` SDK.
6 +//!
7 +//! Tools call the same library functions the CLI handlers use, but return
8 +//! structured JSON rather than rendering spinners or a TUI — the stdout stream
9 +//! is the JSON-RPC channel and must stay free of console output. Logging still
10 +//! goes to the on-disk log file (set up by `main`), never to stdout.
11 +
12 +use {
13 + crate::{account::lib::is_logged_in, client, token::get_smb_token::get_smb_token},
14 + anyhow::{anyhow, Result},
15 + rmcp::{
16 + handler::server::ServerHandler,
17 + model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
18 + tool, tool_handler, tool_router,
19 + transport::stdio,
20 + ErrorData, ServiceExt,
21 + },
22 + smbcloud_auth::me::me,
23 + smbcloud_network::environment::Environment,
24 +};
25 +
26 +/// The smbCloud MCP server. Holds the environment selected on the command line
27 +/// so every tool talks to the same API host and on-disk state dir.
28 +pub struct SmbMcpServer {
29 + environment: Environment,
30 +}
31 +
32 +impl SmbMcpServer {
33 + pub fn new(environment: Environment) -> Self {
34 + Self { environment }
35 + }
36 +}
37 +
38 +#[tool_router]
39 +impl SmbMcpServer {
40 + #[tool(description = "Get the authenticated smbCloud user's account info. \
41 + Requires a prior `smb login`; returns the user as JSON.")]
42 + async fn me(&self) -> Result<CallToolResult, ErrorData> {
43 + if !is_logged_in(self.environment) {
44 + return Err(ErrorData::invalid_request(
45 + "Not logged in. Run `smb login` first.",
46 + None,
47 + ));
48 + }
49 + let token = get_smb_token(self.environment)
50 + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
51 + let user = me(self.environment, client(), &token)
52 + .await
53 + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
54 + let content = ContentBlock::json(&user)?;
55 + Ok(CallToolResult::success(vec![content]))
56 + }
57 +}
58 +
59 +#[tool_handler]
60 +impl ServerHandler for SmbMcpServer {
61 + fn get_info(&self) -> ServerInfo {
62 + // `Implementation` is `#[non_exhaustive]`, so start from the build-env
63 + // default and override the identity fields to report `smb`, not `rmcp`.
64 + let mut server_info = Implementation::from_build_env();
65 + server_info.name = "smb".to_string();
66 + server_info.version = env!("CARGO_PKG_VERSION").to_string();
67 +
68 + ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
69 + .with_server_info(server_info)
70 + .with_instructions(
71 + "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
72 + `smb login`; tools run non-interactively.",
73 + )
74 + }
75 +}
76 +
77 +/// Run the MCP server over stdio until the client disconnects.
78 +pub async fn serve(environment: Environment) -> Result<()> {
79 + let running = SmbMcpServer::new(environment)
80 + .serve(stdio())
81 + .await
82 + .map_err(|e| anyhow!("Failed to start MCP server: {e}"))?;
83 + running
84 + .waiting()
85 + .await
86 + .map_err(|e| anyhow!("MCP server stopped unexpectedly: {e}"))?;
87 + Ok(())
88 +}
crates/cli/src/project/crud_delete.rs
+5 -14
@@ -2,12 +2,8 @@ use crate::client;
2 use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 account::lib::is_logged_in,
5 - ci::{interactive_message, is_ci},
5 cli::CommandResult,
7 - ui::{
8 - confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message,
9 - succeed_symbol,
10 - },
6 + ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
7 };
8 use anyhow::{anyhow, Result};
9 use smbcloud_network::environment::Environment;
@@ -19,15 +15,10 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
15 return Err(anyhow!(fail_message("Please log in with `smb init`.")));
16 }
17
22 - // Deletion confirmation is a full-screen TUI; refuse in CI rather than
23 - // deleting unconfirmed.
24 - if is_ci() {
25 - return Err(anyhow!(fail_message(&interactive_message(
26 - "Project deletion confirmation"
27 - ))));
28 - }
29 -
30 - let confirmed = confirm_delete_tui(&format!("Delete project #{id}")).map_err(|e| anyhow!(e))?;
18 + let confirmed = confirm_delete(
19 + "Project deletion confirmation",
20 + &format!("Delete project #{id}"),
21 + )?;
22
23 let spinner = Spinner::new(
24 spinners::Spinners::SimpleDotsScrolling,
crates/cli/src/project/crud_read.rs
+12 -3
@@ -4,9 +4,10 @@ use crate::client;
4 use crate::token::get_smb_token::get_smb_token;
5 use crate::{
6 cli::CommandResult,
7 + interface::is_tui,
8 project::deploy_target::resolve_frontend_app_for_project,
9 ui::{
9 - fail_message, fail_symbol, project_detail_view::show_project_detail_tui,
10 + fail_message, fail_symbol, plain, project_detail_view::show_project_detail_tui,
11 project_table::show_projects_tui, succeed_message, succeed_symbol,
12 },
13 };
@@ -83,11 +84,19 @@ pub(crate) fn show_projects(projects: Vec<Project>) -> Result<()> {
84 if projects.is_empty() {
85 return Ok(());
86 }
86 - show_projects_tui(projects).map_err(|e| anyhow!(e))
87 + if is_tui() {
88 + return show_projects_tui(projects).map_err(|e| anyhow!(e));
89 + }
90 + plain::render_projects(&projects);
91 + Ok(())
92 }
93
94 pub(crate) fn show_project_detail(project: &Project) -> Result<()> {
90 - show_project_detail_tui(project).map_err(|e| anyhow!(e))
95 + if is_tui() {
96 + return show_project_detail_tui(project).map_err(|e| anyhow!(e));
97 + }
98 + plain::render_project_detail(project);
99 + Ok(())
100 }
101
102 pub(crate) async fn process_project_use(env: Environment, id: String) -> Result<CommandResult> {
crates/cli/src/project/deployment.rs
+12 -3
@@ -3,9 +3,10 @@ use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 cli::CommandResult,
5 deploy::config::{check_project, get_config},
6 + interface::is_tui,
7 ui::{
8 deployment_detail_view::show_deployment_detail_tui, deployment_table::show_deployments_tui,
8 - succeed_message, succeed_symbol,
9 + plain, succeed_message, succeed_symbol,
10 },
11 };
12 use anyhow::{anyhow, Result};
@@ -38,13 +39,21 @@ pub(crate) async fn process_deployment(
39 )
40 .await?;
41 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded"));
41 - show_deployment_detail_tui(&deployment).map_err(|e| anyhow!(e))?;
42 + if is_tui() {
43 + show_deployment_detail_tui(&deployment).map_err(|e| anyhow!(e))?;
44 + } else {
45 + plain::render_deployment_detail(&deployment);
46 + }
47 } else {
48 // List all deployments for the project
49 let access_token = get_smb_token(env)?;
50 let deployments = get_deployments(env, client(), access_token, config.project.id).await?;
51 spinner.stop_and_persist(&succeed_symbol(), succeed_message("Load all deployments"));
47 - show_deployments_tui(deployments).map_err(|e| anyhow!(e))?;
52 + if is_tui() {
53 + show_deployments_tui(deployments).map_err(|e| anyhow!(e))?;
54 + } else {
55 + plain::render_deployments(&deployments);
56 + }
57 };
58
59 Ok(CommandResult {
crates/cli/src/ui/mod.rs
+1
@@ -2,6 +2,7 @@ pub mod confirm_dialog;
2 pub mod deployment_detail_view;
3 pub mod deployment_table;
4 pub mod me_view;
5 +pub mod plain;
6 pub mod project_detail_view;
7 pub mod project_table;
8 pub mod prompt;
crates/cli/src/ui/plain.rs new
+113
@@ -0,0 +1,113 @@
1 +//! Plain-text renderers for the headless interface.
2 +//!
3 +//! The default (headless) interface prints line-based plain text instead of
4 +//! taking over the terminal with a full-screen `ratatui` view. These renderers
5 +//! are the headless counterparts to the `show_*_tui` entry points; a handler
6 +//! chooses between them with [`crate::interface::is_tui`].
7 +
8 +use {
9 + crate::ui::highlight,
10 + smbcloud_model::{
11 + account::User,
12 + project::{Deployment, Project},
13 + },
14 + tabled::{builder::Builder, settings::Style},
15 +};
16 +
17 +/// Render the authenticated user's account details as a plain key/value block.
18 +pub fn render_user(user: &User) {
19 + println!();
20 + println!(" {}", highlight(&user.email));
21 + println!(" {:<14}{}", "ID", user.id);
22 + println!(
23 + " {:<14}{}",
24 + "Member since",
25 + user.created_at.format("%Y-%m-%d")
26 + );
27 + println!(
28 + " {:<14}{}",
29 + "Last updated",
30 + user.updated_at.format("%Y-%m-%d")
31 + );
32 + println!();
33 +}
34 +
35 +/// Render the project list as a plain table.
36 +pub fn render_projects(projects: &[Project]) {
37 + if projects.is_empty() {
38 + println!("No projects found.");
39 + return;
40 + }
41 + let mut builder = Builder::default();
42 + builder.push_record(["ID", "Name", "Runner", "Repository"]);
43 + for project in projects {
44 + builder.push_record([
45 + project.id.to_string(),
46 + project.name.clone(),
47 + project.runner.to_string(),
48 + project.repository.clone().unwrap_or_default(),
49 + ]);
50 + }
51 + let mut table = builder.build();
52 + table.with(Style::rounded());
53 + println!("{table}");
54 +}
55 +
56 +/// Render a single project's details as a plain key/value block.
57 +pub fn render_project_detail(project: &Project) {
58 + println!();
59 + println!(" {}", highlight(&project.name));
60 + println!(" {:<16}{}", "ID", project.id);
61 + println!(" {:<16}{}", "Runner", project.runner);
62 + println!(" {:<16}{}", "Deployment", project.deployment_method);
63 + field_opt("Repository", project.repository.as_deref());
64 + field_opt("Description", project.description.as_deref());
65 + field_opt("Path", project.path.as_deref());
66 + println!();
67 +}
68 +
69 +/// Render the deployment list as a plain table.
70 +pub fn render_deployments(deployments: &[Deployment]) {
71 + if deployments.is_empty() {
72 + println!("No deployments found.");
73 + return;
74 + }
75 + let mut builder = Builder::default();
76 + builder.push_record(["ID", "Commit", "Status", "Created"]);
77 + for deployment in deployments {
78 + builder.push_record([
79 + deployment.id.to_string(),
80 + deployment.commit_hash.chars().take(8).collect::<String>(),
81 + deployment.status.to_string(),
82 + deployment.created_at.format("%Y-%m-%d %H:%M").to_string(),
83 + ]);
84 + }
85 + let mut table = builder.build();
86 + table.with(Style::rounded());
87 + println!("{table}");
88 +}
89 +
90 +/// Render a single deployment's details as a plain key/value block.
91 +pub fn render_deployment_detail(deployment: &Deployment) {
92 + println!();
93 + println!(" {}", highlight(&format!("Deployment #{}", deployment.id)));
94 + println!(" {:<16}{}", "Project", deployment.project_id);
95 + println!(" {:<16}{}", "Commit", deployment.commit_hash);
96 + println!(" {:<16}{}", "Status", deployment.status);
97 + field_opt("App", deployment.frontend_app_name.as_deref());
98 + println!(
99 + " {:<16}{}",
100 + "Created",
101 + deployment.created_at.format("%Y-%m-%d %H:%M")
102 + );
103 + println!();
104 +}
105 +
106 +/// Print a `label: value` line only when the optional value is present.
107 +fn field_opt(label: &str, value: Option<&str>) {
108 + if let Some(value) = value {
109 + if !value.is_empty() {
110 + println!(" {:<16}{}", label, value);
111 + }
112 + }
113 +}
crates/cli/src/ui/prompt.rs
+16 -1
@@ -16,7 +16,8 @@
16 use {
17 crate::{
18 ci::{interactive_message, is_ci},
19 - ui::fail_message,
19 + interface::is_tui,
20 + ui::{confirm_dialog::confirm_delete_tui, fail_message},
21 },
22 anyhow::{anyhow, Result},
23 dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Password, Select},
@@ -30,6 +31,20 @@ fn io_error(err: dialoguer::Error) -> anyhow::Error {
31 anyhow!(fail_message(&format!("Prompt failed: {err}")))
32 }
33
34 +/// Destructive-action confirmation, dispatched by interface. Refuses in CI
35 +/// (deleting unconfirmed is never safe); renders the full-screen danger dialog
36 +/// under `--tui`; otherwise asks inline with a `false` default. `what` names
37 +/// the confirmation for the CI error; `message` is shown to the user.
38 +pub fn confirm_delete(what: &str, message: &str) -> Result<bool> {
39 + if is_ci() {
40 + return Err(ci_required(what));
41 + }
42 + if is_tui() {
43 + return confirm_delete_tui(message).map_err(|e| anyhow!(fail_message(&e.to_string())));
44 + }
45 + confirm(message, false)
46 +}
47 +
48 /// Yes/no confirmation. In CI mode returns `default` without prompting.
49 pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
50 if is_ci() {