Skip to main content

soma_cli/
lib.rs

1//! CLI — thin shim that parses args, dispatches service actions, formats output.
2//!
3//! The CLI uses the same service layer as the MCP server. No business logic lives here.
4//!
5//! **Customize**: add subcommands to match your service's operations.
6//!
7//! # Usage
8//!
9//! ```text
10//! soma greet --name Alice
11//! soma echo --message "Hello!"
12//! soma status
13//! soma doctor [--json]
14//! ```
15
16use anyhow::{anyhow, Result};
17use serde_json::Value;
18use soma_application::{ExecuteActionRequest, ExecutionContext, SomaApplication};
19use soma_cli_core::common_args::{
20    parse_bool_flag as core_parse_bool_flag,
21    parse_optional_value_flag as core_parse_optional_value_flag,
22    parse_required_value_flag as core_parse_required_value_flag, reject_args as core_reject_args,
23};
24use soma_cli_core::confirmation::confirm_typed;
25use soma_domain::actions::{ActionSpec, SomaAction};
26use soma_domain::{Confirmation, RequestId, Surface};
27use std::io::{BufRead, IsTerminal, Write};
28use std::sync::{atomic::AtomicU64, atomic::Ordering, Arc};
29
30// CUSTOMIZE: The doctor module is the §48 reference implementation.
31//           Import it from here and wire into run() below.
32pub mod doctor;
33mod provider_command;
34mod providers;
35pub mod setup;
36pub mod watch;
37
38pub use provider_command::ProviderCommand;
39use provider_command::{parse_providers_command, run_provider_management_command};
40pub use setup::{apply_plugin_options, run_setup, SetupCommand};
41
42pub const USAGE: &str = "Usage:
43  soma mcp              Start MCP stdio transport
44  soma serve            Start HTTP MCP + REST + Web server
45
46  soma greet [--name NAME]       Greet NAME (or the world)
47  soma echo --message MSG        Echo MSG back
48  soma status                    Show server status
49  soma help                      Show JSON action reference
50  soma doctor [--json]           Run environment pre-flight checks
51  soma watch [--url URL] [--interval N]  Poll /health and emit on state change
52  soma setup check               Check plugin setup without mutating appdata
53  soma setup repair              Create missing appdata/env setup files
54  soma setup plugin-hook [--no-repair]  Plugin hook JSON contract
55  soma providers validate        Validate provider manifests and compiled schemas
56  soma providers inspect         Show provider manifests, surfaces, and capability posture
57  soma providers test ACTION [--json JSON]  Dispatch one provider action through the registry
58  soma providers list [--dir DIR] [--json]    List drop-in provider files (no execution)
59  soma providers lint [--dir DIR] [--json]    Lint drop-in provider files (no execution)
60  soma providers status [--dir DIR] [--json]  Summarize drop-in provider files (no execution)
61  soma package generate [--write|--check]  Refresh generated provider docs, skills, and plugin metadata
62
63  soma --help                    Show this help
64  soma --version                 Show version
65
66Environment:
67  SOMA_API_URL          Deployed platform API or upstream service URL
68  SOMA_API_KEY          Bearer token or upstream service API key
69  SOMA_MCP_HOST         HTTP server bind host (default 127.0.0.1)
70  SOMA_MCP_PORT         HTTP server bind port (default 40060)
71  SOMA_MCP_NO_AUTH      Disable auth (loopback only)
72  SOMA_MCP_TOKEN        Static bearer token
73  RUST_LOG                 Log filter (e.g. info,rmcp=warn)";
74
75pub fn usage() -> &'static str {
76    USAGE
77}
78
79#[derive(Debug, PartialEq, Eq)]
80pub enum Command {
81    Greet {
82        name: Option<String>,
83    },
84    Echo {
85        message: String,
86    },
87    Status,
88    Help,
89    /// Pre-flight environment validation (§48).
90    ///
91    /// CUSTOMIZE: Always keep this command. It is the operator's first stop
92    /// when setting up or debugging the service.
93    Doctor {
94        /// Output JSON instead of human-readable text.
95        json: bool,
96    },
97    /// Poll the MCP server health endpoint and emit a line on every state change.
98    ///
99    /// Designed to be run as a plugin monitor — stdout is the event stream,
100    /// stderr is debug output. Exits only on CTRL+C.
101    Watch {
102        /// Base URL of the MCP server (default: http://localhost:{SOMA_MCP_PORT}).
103        url: Option<String>,
104        /// Poll interval in seconds (default: 10).
105        interval: u64,
106    },
107    Provider {
108        command: String,
109        json: Value,
110    },
111    Providers(ProviderCommand),
112    PackageGenerate {
113        write: bool,
114    },
115    Setup(SetupCommand),
116}
117
118pub struct CliInvocation {
119    pub command: Command,
120}
121
122impl From<Command> for CliInvocation {
123    fn from(command: Command) -> Self {
124        Self { command }
125    }
126}
127
128pub type CliError = anyhow::Error;
129
130pub trait CliIo {
131    fn stdout(&mut self, output: &str) -> Result<()>;
132    fn stderr(&mut self, output: &str) -> Result<()>;
133    fn confirm_destructive(&mut self, action: &str) -> Result<()>;
134}
135
136pub struct StandardCliIo;
137
138impl CliIo for StandardCliIo {
139    fn stdout(&mut self, output: &str) -> Result<()> {
140        writeln!(std::io::stdout(), "{output}")?;
141        Ok(())
142    }
143
144    fn stderr(&mut self, output: &str) -> Result<()> {
145        writeln!(std::io::stderr(), "{output}")?;
146        Ok(())
147    }
148
149    fn confirm_destructive(&mut self, action: &str) -> Result<()> {
150        if !std::io::stdin().is_terminal() {
151            return Err(anyhow!(
152                "pass -y / --yes to confirm destructive action `{action}`"
153            ));
154        }
155        confirm_destructive_action_from_io(
156            action,
157            &mut std::io::stdin().lock(),
158            &mut std::io::stderr(),
159        )
160    }
161}
162
163/// Parse CLI arguments from `std::env::args()`.
164///
165/// Returns `None` if the first argument is not a known subcommand.
166/// **Customize**: extend this to use clap or another arg parser for a real CLI.
167/// This is intentionally minimal so Soma compiles without extra deps.
168///
169/// # CUSTOMIZE: Adding a new subcommand
170///
171/// 1. Add a variant to `Command` above.
172/// 2. Add a match arm here to construct it from args.
173/// 3. Add a dispatch arm in `run()` below.
174/// 4. Update `USAGE` above.
175pub fn parse_args() -> Result<Option<Command>> {
176    parse_args_from(std::env::args().skip(1))
177}
178
179pub fn parse_args_from<I, S>(args: I) -> Result<Option<Command>>
180where
181    I: IntoIterator<Item = S>,
182    S: Into<String>,
183{
184    let args: Vec<String> = args.into_iter().map(Into::into).collect();
185    let command = match args.as_slice() {
186        [] => None,
187        [subcommand, rest @ ..] => match subcommand.as_str() {
188            "greet" => {
189                let name = parse_optional_value_flag(rest, "greet", "--name")?;
190                Some(Command::Greet { name })
191            }
192            "echo" => {
193                let message = parse_required_value_flag(rest, "echo", "--message")?
194                    .filter(|m| !m.is_empty())
195                    .ok_or_else(|| anyhow!("echo requires non-empty --message"))?;
196                Some(Command::Echo { message })
197            }
198            "status" => {
199                reject_args(rest, "status")?;
200                Some(Command::Status)
201            }
202            "help" => {
203                reject_args(rest, "help")?;
204                Some(Command::Help)
205            }
206            // §48: doctor is always parsed here, dispatched via apps/soma::local::run
207            // (called from lib.rs::run(), called from bin/soma.rs).
208            // CUSTOMIZE: Keep this arm. It routes to doctor::run_doctor() which needs
209            //           the full Config (not just SomaConfig), so apps/soma handles it.
210            "doctor" => {
211                let json = parse_bool_flag(rest, "doctor", "--json")?;
212                Some(Command::Doctor { json })
213            }
214            "watch" => {
215                let (url, interval_arg) = parse_watch_flags(rest)?;
216                let interval = match interval_arg {
217                    Some(v) => v.parse().map_err(|_| {
218                        anyhow!("watch --interval must be a positive integer number of seconds")
219                    })?,
220                    None => 10,
221                };
222                if interval == 0 {
223                    return Err(anyhow!(
224                        "watch --interval must be a positive integer number of seconds"
225                    ));
226                }
227                Some(Command::Watch { url, interval })
228            }
229            "setup" => match rest {
230                [action, flags @ ..] if action == "check" => {
231                    reject_args(flags, "setup check")?;
232                    Some(Command::Setup(SetupCommand::Check))
233                }
234                [action, flags @ ..] if action == "repair" => {
235                    reject_args(flags, "setup repair")?;
236                    Some(Command::Setup(SetupCommand::Repair))
237                }
238                [action, flags @ ..] if action == "install" => {
239                    reject_args(flags, "setup install")?;
240                    Some(Command::Setup(SetupCommand::Install))
241                }
242                [action, flags @ ..] if action == "plugin-hook" => {
243                    let no_repair = parse_bool_flag(flags, "setup plugin-hook", "--no-repair")?;
244                    Some(Command::Setup(SetupCommand::PluginHook { no_repair }))
245                }
246                _ => None,
247            },
248            "package" => match rest {
249                [action, flags @ ..] if action == "generate" => Some(Command::PackageGenerate {
250                    write: parse_package_generate_flags(flags)?,
251                }),
252                _ => None,
253            },
254            "providers" => Some(parse_providers_command(rest)?),
255            other => Some(parse_provider_command(other, rest)?),
256        },
257    };
258    Ok(command)
259}
260
261/// Run a CLI command, print the result, and exit.
262///
263/// `Doctor`, `Watch`, `Setup`, and package generation are handled by the app
264/// composition layer because they are process infrastructure rather than
265/// product actions.
266pub async fn run(
267    application: Arc<SomaApplication>,
268    invocation: CliInvocation,
269    io: &mut dyn CliIo,
270) -> Result<std::process::ExitCode, CliError> {
271    let cmd = invocation.command;
272    if let Command::Providers(command) = &cmd {
273        if command.is_non_executing() {
274            let Command::Providers(command) = cmd else {
275                unreachable!()
276            };
277            providers::run_providers_command(command)?;
278            return Ok(std::process::ExitCode::SUCCESS);
279        }
280    }
281
282    let destructive_confirmed = confirm_command_if_destructive(&cmd, &application, io)?;
283
284    if let Command::Providers(command) = &cmd {
285        let result =
286            run_provider_management_command(command, application.as_ref(), destructive_confirmed)
287                .await?;
288        io.stdout(&soma_cli_core::json::to_pretty_string(&result)?)?;
289        return Ok(std::process::ExitCode::SUCCESS);
290    }
291
292    let request = match service_action_from_command(&cmd) {
293        Some(action) => ExecuteActionRequest {
294            action: action.name().to_owned(),
295            params: cli_params(&action),
296        },
297        None if matches!(cmd, Command::Provider { .. }) => {
298            let Command::Provider { json, .. } = &cmd else {
299                unreachable!()
300            };
301            ExecuteActionRequest {
302                action: provider_action_from_command(&cmd, &application)?,
303                params: json.clone(),
304            }
305        }
306        None => unreachable!("dispatched directly in apps/soma::local::run"),
307    };
308    let result = match application
309        .execute_action(request, cli_execution_context(destructive_confirmed))
310        .await
311    {
312        Ok(output) => output.output,
313        Err(error) => {
314            io.stderr(&soma_cli_core::json::to_pretty_string(&error)?)?;
315            return Err(anyhow!(error.message));
316        }
317    };
318
319    io.stdout(&soma_cli_core::json::to_pretty_string(&result)?)?;
320    Ok(std::process::ExitCode::SUCCESS)
321}
322
323pub fn run_non_executing_provider_command(command: ProviderCommand) -> Result<()> {
324    if !command.is_non_executing() {
325        return Err(anyhow!(
326            "provider command requires an initialized Soma application"
327        ));
328    }
329    providers::run_providers_command(command)
330}
331
332pub(crate) fn cli_execution_context(destructive_confirmed: bool) -> ExecutionContext {
333    static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
334    let sequence = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
335    let request_id = RequestId::new(format!("cli-{}-{sequence}", std::process::id()))
336        .expect("generated CLI request ids are valid");
337    let mut context = ExecutionContext::loopback(Surface::Cli, request_id);
338    context.destructive_confirmation = if destructive_confirmed {
339        Confirmation::Confirmed
340    } else {
341        Confirmation::Missing
342    };
343    context
344}
345
346#[cfg(test)]
347fn format_cli_tool_error(error: &soma_domain::errors::ToolError) -> String {
348    let mut lines = vec![
349        format!("error: {}", error.message),
350        format!("code: {}", error.code),
351        format!("kind: {}", error.kind.as_str()),
352        format!("retryable: {}", error.retryable),
353        format!("remediation: {}", error.remediation),
354    ];
355    if let Some(field) = &error.field {
356        lines.push(format!("field: {field}"));
357    }
358    if let Some(bad_value) = &error.bad_value {
359        lines.push(format!("bad_value: {bad_value}"));
360    }
361    lines.join("\n")
362}
363
364fn confirm_command_if_destructive(
365    cmd: &Command,
366    application: &SomaApplication,
367    io: &mut dyn CliIo,
368) -> Result<bool> {
369    let Some(action) = command_action_name(cmd, application)? else {
370        return Ok(false);
371    };
372    if !application.action_requires_confirmation(&action) {
373        return Ok(false);
374    }
375    io.confirm_destructive(&action)?;
376    Ok(true)
377}
378
379fn command_action_name(cmd: &Command, application: &SomaApplication) -> Result<Option<String>> {
380    match cmd {
381        Command::Provider { .. } => provider_action_from_command(cmd, application).map(Some),
382        Command::Providers(ProviderCommand::Test { action, .. }) => Ok(Some(action.clone())),
383        Command::Providers(_) => Ok(None),
384        _ => Ok(service_action_from_command(cmd).map(|action| action.name().to_owned())),
385    }
386}
387
388fn provider_action_from_command(cmd: &Command, application: &SomaApplication) -> Result<String> {
389    let Command::Provider { command, .. } = cmd else {
390        return Err(anyhow!("command is not a dynamic provider command"));
391    };
392    application
393        .resolve_cli_action(command)
394        .map_err(|_| anyhow!("unknown dynamic provider CLI command `{command}`"))
395}
396
397fn service_action_from_command(cmd: &Command) -> Option<SomaAction> {
398    match cmd {
399        Command::Greet { name } => Some(SomaAction::Greet { name: name.clone() }),
400        Command::Echo { message } => Some(SomaAction::Echo {
401            message: message.clone(),
402        }),
403        Command::Status => Some(SomaAction::Status),
404        Command::Help => Some(SomaAction::Help),
405        Command::Doctor { .. }
406        | Command::Watch { .. }
407        | Command::Provider { .. }
408        | Command::Providers(_)
409        | Command::PackageGenerate { .. }
410        | Command::Setup(_) => None,
411    }
412}
413
414fn cli_params(action: &SomaAction) -> serde_json::Value {
415    match action {
416        SomaAction::Greet { name } => match name {
417            Some(name) => serde_json::json!({ "name": name }),
418            None => serde_json::json!({}),
419        },
420        SomaAction::Echo { message } => serde_json::json!({ "message": message }),
421        SomaAction::Status
422        | SomaAction::Help
423        | SomaAction::ElicitName
424        | SomaAction::ScaffoldIntent => serde_json::json!({}),
425    }
426}
427
428fn parse_provider_command(command: &str, args: &[String]) -> Result<Command> {
429    if reserved_cli_command(command) {
430        return Err(anyhow!("`{command}` is a reserved infrastructure command"));
431    }
432    match args {
433        [flag, payload] if flag == "--json" => Ok(Command::Provider {
434            command: command.to_owned(),
435            json: serde_json::from_str(payload)
436                .map_err(|error| anyhow!("{command} --json must be valid JSON: {error}"))?,
437        }),
438        [] => Ok(Command::Provider {
439            command: command.to_owned(),
440            json: serde_json::json!({}),
441        }),
442        _ => Ok(Command::Provider {
443            command: command.to_owned(),
444            json: parse_provider_flags(command, args)?,
445        }),
446    }
447}
448
449fn parse_package_generate_flags(args: &[String]) -> Result<bool> {
450    match args {
451        [] => Ok(false),
452        [flag] if flag == "--check" => Ok(false),
453        [flag] if flag == "--write" => Ok(true),
454        [unexpected, ..] => Err(anyhow!(
455            "package generate accepts only --write or --check, got `{unexpected}`"
456        )),
457    }
458}
459
460fn parse_provider_flags(command: &str, args: &[String]) -> Result<serde_json::Value> {
461    let mut object = serde_json::Map::new();
462    let mut chunks = args.chunks_exact(2);
463    for pair in &mut chunks {
464        let [flag, value] = pair else { unreachable!() };
465        let key = flag
466            .strip_prefix("--")
467            .filter(|key| !key.is_empty())
468            .ok_or_else(|| {
469                anyhow!("{command} dynamic provider flags must use --name value pairs or --json")
470            })?;
471        object.insert(key.replace('-', "_"), scalar_json(value));
472    }
473    if !chunks.remainder().is_empty() {
474        return Err(anyhow!(
475            "{command} dynamic provider flags must use --name value pairs or --json"
476        ));
477    }
478    Ok(serde_json::Value::Object(object))
479}
480
481fn scalar_json(value: &str) -> serde_json::Value {
482    if value == "true" {
483        serde_json::Value::Bool(true)
484    } else if value == "false" {
485        serde_json::Value::Bool(false)
486    } else if let Ok(number) = value.parse::<i64>() {
487        serde_json::Value::Number(number.into())
488    } else if let Ok(number) = value.parse::<f64>() {
489        serde_json::Number::from_f64(number)
490            .map(serde_json::Value::Number)
491            .unwrap_or_else(|| serde_json::Value::String(value.to_owned()))
492    } else {
493        serde_json::Value::String(value.to_owned())
494    }
495}
496
497// Must match soma_domain::provider_validation's RESERVED_CLI_COMMANDS
498// exactly — that list is what soma providers validate/lint checks against,
499// so a name reserved only here passes manifest validation but is
500// unreachable once it hits this parser.
501fn reserved_cli_command(command: &str) -> bool {
502    matches!(
503        command,
504        "serve"
505            | "mcp"
506            | "doctor"
507            | "watch"
508            | "setup"
509            | "package"
510            | "tools"
511            | "providers"
512            | "openapi"
513            | "help"
514    )
515}
516
517pub fn run_package_generate(write: bool) -> Result<()> {
518    let mode = if write { "--write" } else { "--check" };
519    let mut command = std::process::Command::new("cargo");
520    command.env_remove("CARGO_PROFILE_DEV_CODEGEN_BACKEND");
521    let status = command
522        .args(["xtask", "generate-provider-surfaces", mode])
523        .status()
524        .map_err(|error| {
525            anyhow!("failed to run cargo xtask generate-provider-surfaces: {error}")
526        })?;
527    if !status.success() {
528        return Err(anyhow!(
529            "cargo xtask generate-provider-surfaces {mode} failed with {status}"
530        ));
531    }
532    println!(
533        "{}",
534        serde_json::to_string_pretty(&serde_json::json!({
535            "ok": true,
536            "changed": write,
537            "command": "package generate",
538            "mode": if write { "write" } else { "check" }
539        }))?
540    );
541    Ok(())
542}
543
544pub fn confirm_destructive_action_allowed(
545    actions: &[ActionSpec],
546    action: &str,
547    yes: bool,
548    stdin_is_terminal: bool,
549) -> Result<()> {
550    if yes
551        || !actions
552            .iter()
553            .any(|spec| spec.name == action && spec.destructive)
554    {
555        return Ok(());
556    }
557    if !stdin_is_terminal {
558        return Err(anyhow!(
559            "pass -y / --yes to confirm destructive action `{action}`"
560        ));
561    }
562    confirm_destructive_action_from_io(action, &mut std::io::stdin().lock(), &mut std::io::stderr())
563}
564
565fn confirm_destructive_action_from_io<R, W>(
566    action: &str,
567    reader: &mut R,
568    writer: &mut W,
569) -> Result<()>
570where
571    R: BufRead,
572    W: Write,
573{
574    let prompt = format!("Action `{action}` is destructive. Type `{action}` to continue: ");
575    if confirm_typed(writer, reader, &prompt, action)? {
576        Ok(())
577    } else {
578        Err(anyhow!("aborted by user"))
579    }
580}
581
582// ── arg parsing helpers ───────────────────────────────────────────────────────
583
584fn reject_args(args: &[String], command: &str) -> Result<()> {
585    Ok(core_reject_args(args, command)?)
586}
587
588fn parse_bool_flag(args: &[String], command: &str, flag: &str) -> Result<bool> {
589    Ok(core_parse_bool_flag(args, command, flag)?)
590}
591
592fn parse_optional_value_flag(args: &[String], command: &str, flag: &str) -> Result<Option<String>> {
593    Ok(core_parse_optional_value_flag(args, command, flag)?)
594}
595
596fn parse_required_value_flag(args: &[String], command: &str, flag: &str) -> Result<Option<String>> {
597    Ok(core_parse_required_value_flag(args, command, flag)?)
598}
599
600fn parse_watch_flags(args: &[String]) -> Result<(Option<String>, Option<String>)> {
601    let mut url = None;
602    let mut interval = None;
603    let mut index = 0;
604    while index < args.len() {
605        let flag = args[index].as_str();
606        let target = match flag {
607            "--url" => &mut url,
608            "--interval" => &mut interval,
609            _ => return Err(anyhow!("watch does not accept argument `{flag}`")),
610        };
611        if target.is_some() {
612            return Err(anyhow!("watch received duplicate {flag}"));
613        }
614        let Some(value) = args.get(index + 1) else {
615            return Err(anyhow!("watch requires a value after {flag}"));
616        };
617        if value.starts_with("--") {
618            return Err(anyhow!("watch requires a value after {flag}"));
619        }
620        *target = Some(value.clone());
621        index += 2;
622    }
623    Ok((url, interval))
624}
625
626#[cfg(test)]
627#[path = "cli_tests.rs"]
628mod tests;