1use 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
30pub 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 Doctor {
94 json: bool,
96 },
97 Watch {
102 url: Option<String>,
104 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
163pub 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 "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
261pub 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
497fn 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
582fn 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;