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