1use 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
30pub 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 Doctor {
111 json: bool,
113 },
114 Watch {
119 url: Option<String>,
121 interval: u64,
123 },
124 Provider {
125 command: String,
126 json: Value,
127 },
128 Providers(ProviderCommand),
129 PackageGenerate {
130 write: bool,
131 },
132 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
197pub 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 "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
296pub 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
570fn 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
656fn 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;