feat(cli): add cron subcommand for managing scheduled routines

Rebase onto staging branch and address collaborator review:
  - Fix .unwrap_or(None) → proper error propagation in set_enabled()
  - Add --yes/-y flag for non-interactive deletion with confirmation prompt
  - Add --json flag for machine-readable output in list and history
  - Preserve error context chain with {e:#} in run_cron_cli()

  Note: GATEWAY_USER_ID is trusted from the environment; future work may
  add authentication for multi-tenant deployments.
This commit is contained in:
reidliu41
2026-03-12 11:00:16 -07:00
committed by Zaki
parent 5a62ceaa99
commit 403f6f504f
8 changed files with 859 additions and 1 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
| `plugins` | ✅ | ❌ | P3 | Plugin management |
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
| `cron` | ✅ | | P2 | Scheduled jobs (model/thinking fields in edit) |
| `cron` | ✅ | 🚧 | P2 | list/create/edit/enable/disable/delete/history; TODO: `cron run`, model/thinking fields |
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
| `message send` | ✅ | ❌ | P2 | Send to channels |
| `browser` | ✅ | ❌ | P3 | Browser automation |
+822
View File
@@ -0,0 +1,822 @@
//! `ironclaw cron` — manage scheduled routines from the CLI.
//!
//! Provides subcommands for listing, creating, editing, enabling/disabling,
//! deleting, and viewing run history of cron-triggered routines without
//! starting the full agent.
use std::sync::Arc;
use chrono::{DateTime, Utc};
use clap::Subcommand;
use uuid::Uuid;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
};
use crate::db::Database;
/// Cron subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum CronCommand {
/// List routines (cron-triggered by default, --all for all types)
List {
/// Show all trigger types, not just cron
#[arg(long)]
all: bool,
/// Include disabled routines
#[arg(long)]
disabled: bool,
/// Output as JSON (for scripting)
#[arg(long)]
json: bool,
},
/// Create a new cron routine
#[command(alias = "add")]
Create {
/// Routine name (must be unique per user)
#[arg(long)]
name: String,
/// Cron schedule (6-field: "sec min hour day month weekday")
#[arg(long)]
schedule: String,
/// Prompt for the LLM
#[arg(long)]
prompt: String,
/// Optional description
#[arg(long, default_value = "")]
description: String,
/// IANA timezone (e.g. "America/New_York")
#[arg(long)]
timezone: Option<String>,
/// Cooldown between fires in seconds
#[arg(long, default_value = "300")]
cooldown: u64,
/// Notification channel
#[arg(long)]
notify_channel: Option<String>,
},
/// Edit an existing routine
#[command(alias = "update")]
Edit {
/// Routine name
#[arg(long)]
name: String,
/// New schedule
#[arg(long)]
schedule: Option<String>,
/// New prompt
#[arg(long)]
prompt: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
/// New timezone
#[arg(long)]
timezone: Option<String>,
/// New cooldown in seconds
#[arg(long)]
cooldown: Option<u64>,
},
/// Enable a routine
Enable {
/// Routine name
name: String,
},
/// Disable a routine
Disable {
/// Routine name
name: String,
},
/// Delete a routine
#[command(alias = "rm")]
Delete {
/// Routine name
name: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
/// Show run history for a routine
#[command(alias = "runs")]
History {
/// Routine name
name: String,
/// Maximum number of runs to show
#[arg(short, long, default_value = "10")]
limit: i64,
/// Output as JSON (for scripting)
#[arg(long)]
json: bool,
},
}
/// Run a cron CLI command against the database.
pub async fn run_cron_command(
cmd: CronCommand,
db: Arc<dyn Database>,
user_id: &str,
) -> anyhow::Result<()> {
match cmd {
CronCommand::List {
all,
disabled,
json,
} => list(&db, user_id, all, disabled, json).await,
CronCommand::Create {
name,
schedule,
prompt,
description,
timezone,
cooldown,
notify_channel,
} => {
create(
&db,
user_id,
&name,
&schedule,
&prompt,
&description,
timezone.as_deref(),
cooldown,
notify_channel,
)
.await
}
CronCommand::Edit {
name,
schedule,
prompt,
description,
timezone,
cooldown,
} => {
edit(
&db,
user_id,
&name,
schedule.as_deref(),
prompt.as_deref(),
description.as_deref(),
timezone.as_deref(),
cooldown,
)
.await
}
CronCommand::Enable { name } => set_enabled(&db, user_id, &name, true).await,
CronCommand::Disable { name } => set_enabled(&db, user_id, &name, false).await,
CronCommand::Delete { name, yes } => delete(&db, user_id, &name, yes).await,
CronCommand::History { name, limit, json } => {
history(&db, user_id, &name, limit, json).await
}
}
}
// ── List ────────────────────────────────────────────────────
async fn list(
db: &Arc<dyn Database>,
user_id: &str,
all_types: bool,
show_disabled: bool,
json: bool,
) -> anyhow::Result<()> {
let routines = db.list_routines(user_id).await?;
let filtered: Vec<&Routine> = routines
.iter()
.filter(|r| all_types || r.trigger.type_tag() == "cron")
.filter(|r| show_disabled || r.enabled)
.collect();
if json {
let items: Vec<serde_json::Value> = filtered
.iter()
.map(|r| {
serde_json::json!({
"id": r.id.to_string(),
"name": r.name,
"trigger": r.trigger.type_tag(),
"enabled": r.enabled,
"next_fire_at": r.next_fire_at,
"last_run_at": r.last_run_at,
"run_count": r.run_count,
"consecutive_failures": r.consecutive_failures,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
return Ok(());
}
if filtered.is_empty() {
println!(
"No {} routines found.",
if all_types { "" } else { "cron " }
);
return Ok(());
}
// Header
println!(
"{:<36} {:<20} {:<8} {:<8} {:<22} {:<22} {:>5}",
"ID", "NAME", "TRIGGER", "STATUS", "NEXT FIRE", "LAST RUN", "RUNS"
);
println!("{}", "-".repeat(130));
for r in &filtered {
let status = if r.enabled {
if r.consecutive_failures > 0 {
format!("err({})", r.consecutive_failures)
} else {
"active".to_string()
}
} else {
"disabled".to_string()
};
let next_fire = r
.next_fire_at
.map(format_relative)
.unwrap_or_else(|| "-".to_string());
let last_run = r
.last_run_at
.map(format_relative)
.unwrap_or_else(|| "-".to_string());
let name = truncate(&r.name, 20);
println!(
"{:<36} {:<20} {:<8} {:<8} {:<22} {:<22} {:>5}",
r.id,
name,
r.trigger.type_tag(),
status,
next_fire,
last_run,
r.run_count,
);
}
println!("\n{} routine(s)", filtered.len());
Ok(())
}
// ── Create ──────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
async fn create(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
schedule: &str,
prompt: &str,
description: &str,
timezone: Option<&str>,
cooldown_secs: u64,
notify_channel: Option<String>,
) -> anyhow::Result<()> {
// Validate the cron expression by computing next fire.
let next_fire = next_cron_fire(schedule, timezone)
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
// Check for name conflict.
if db.get_routine_by_name(user_id, name).await?.is_some() {
anyhow::bail!("Routine '{}' already exists", name);
}
let now = Utc::now();
let routine = Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
user_id: user_id.to_string(),
enabled: true,
trigger: Trigger::Cron {
schedule: schedule.to_string(),
timezone: timezone.map(String::from),
},
action: RoutineAction::Lightweight {
prompt: prompt.to_string(),
context_paths: Vec::new(),
max_tokens: 4096,
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(cooldown_secs),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig {
channel: notify_channel,
user: user_id.to_string(),
on_attention: true,
on_failure: true,
on_success: false,
},
last_run_at: None,
next_fire_at: next_fire,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: now,
updated_at: now,
};
db.create_routine(&routine).await?;
println!("Created routine '{}'", name);
println!(" ID: {}", routine.id);
println!(" Schedule: {}", schedule);
if let Some(tz) = timezone {
println!(" Timezone: {}", tz);
}
if let Some(nf) = next_fire {
println!(" Next fire: {}", format_relative(nf));
}
Ok(())
}
// ── Edit ────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
async fn edit(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
schedule: Option<&str>,
prompt: Option<&str>,
description: Option<&str>,
timezone: Option<&str>,
cooldown: Option<u64>,
) -> anyhow::Result<()> {
let mut routine = require_cron_routine(db, user_id, name).await?;
let mut changed = false;
// Update schedule if provided.
if let Some(new_schedule) = schedule {
let tz = timezone.or(match &routine.trigger {
Trigger::Cron { timezone, .. } => timezone.as_deref(),
_ => None,
});
let next_fire = next_cron_fire(new_schedule, tz)
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
routine.trigger = Trigger::Cron {
schedule: new_schedule.to_string(),
timezone: tz.map(String::from),
};
routine.next_fire_at = next_fire;
changed = true;
} else if let Some(tz) = timezone {
// Update only timezone, recompute next fire with existing schedule.
if let Trigger::Cron { ref schedule, .. } = routine.trigger {
let next_fire = next_cron_fire(schedule, Some(tz))
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
routine.trigger = Trigger::Cron {
schedule: schedule.clone(),
timezone: Some(tz.to_string()),
};
routine.next_fire_at = next_fire;
changed = true;
} else {
anyhow::bail!("Cannot set timezone on non-cron trigger");
}
}
if let Some(new_prompt) = prompt {
match &mut routine.action {
RoutineAction::Lightweight { prompt: p, .. } => {
*p = new_prompt.to_string();
changed = true;
}
RoutineAction::FullJob { description: d, .. } => {
*d = new_prompt.to_string();
changed = true;
}
}
}
if let Some(new_desc) = description {
routine.description = new_desc.to_string();
changed = true;
}
if let Some(cd) = cooldown {
routine.guardrails.cooldown = std::time::Duration::from_secs(cd);
changed = true;
}
if !changed {
println!("No changes specified.");
return Ok(());
}
routine.updated_at = Utc::now();
db.update_routine(&routine).await?;
println!("Updated routine '{}'", name);
Ok(())
}
// ── Enable / Disable ────────────────────────────────────────
async fn set_enabled(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
enabled: bool,
) -> anyhow::Result<()> {
let mut routine = require_cron_routine(db, user_id, name).await?;
if routine.enabled == enabled {
println!(
"Routine '{}' is already {}",
name,
if enabled { "enabled" } else { "disabled" }
);
return Ok(());
}
routine.enabled = enabled;
// Recompute next fire when enabling a cron routine.
if enabled
&& let Trigger::Cron {
ref schedule,
ref timezone,
} = routine.trigger
{
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
.map_err(|e| anyhow::anyhow!("Failed to compute next fire for stored schedule: {e}"))?;
}
routine.updated_at = Utc::now();
db.update_routine(&routine).await?;
println!(
"{} routine '{}'",
if enabled { "Enabled" } else { "Disabled" },
name
);
Ok(())
}
// ── Delete ──────────────────────────────────────────────────
async fn delete(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
skip_confirm: bool,
) -> anyhow::Result<()> {
let routine = require_cron_routine(db, user_id, name).await?;
if !skip_confirm {
println!("Routine: {}", routine.name);
println!(" ID: {}", routine.id);
if let Trigger::Cron { ref schedule, .. } = routine.trigger {
println!("Schedule: {}", schedule);
}
println!(" Runs: {}", routine.run_count);
print!("\nDelete this routine? [y/N] ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Cancelled.");
return Ok(());
}
}
let deleted = db.delete_routine(routine.id).await?;
if deleted {
println!("Deleted routine '{}'", name);
} else {
anyhow::bail!("Failed to delete routine '{}'", name);
}
Ok(())
}
// ── History ─────────────────────────────────────────────────
async fn history(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
limit: i64,
json: bool,
) -> anyhow::Result<()> {
let routine = require_cron_routine(db, user_id, name).await?;
let limit = limit.clamp(1, 50);
let runs = db.list_routine_runs(routine.id, limit).await?;
if json {
let items: Vec<serde_json::Value> = runs
.iter()
.map(|run| {
serde_json::json!({
"id": run.id.to_string(),
"status": run.status.to_string(),
"started_at": run.started_at,
"completed_at": run.completed_at,
"result_summary": run.result_summary,
"tokens_used": run.tokens_used,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
return Ok(());
}
if runs.is_empty() {
println!("No runs found for routine '{}'", name);
return Ok(());
}
println!("Run history for '{}' (last {}):\n", name, runs.len());
println!(
"{:<36} {:<8} {:<20} {:<12} SUMMARY",
"RUN ID", "STATUS", "STARTED", "DURATION"
);
println!("{}", "-".repeat(100));
for run in &runs {
let duration = run
.completed_at
.map(|end| {
let secs = (end - run.started_at).num_seconds();
if secs < 60 {
format!("{}s", secs)
} else {
format!("{}m{}s", secs / 60, secs % 60)
}
})
.unwrap_or_else(|| "running".to_string());
let summary = run
.result_summary
.as_deref()
.map(|s| truncate(s, 40))
.unwrap_or_else(|| "-".to_string());
println!(
"{:<36} {:<8} {:<20} {:<12} {}",
run.id,
run.status,
run.started_at.format("%Y-%m-%d %H:%M:%S"),
duration,
summary,
);
}
println!("\n{} run(s) shown", runs.len());
Ok(())
}
// ── Shared lookup ────────────────────────────────────────────
/// Look up a routine by name and verify it has a cron trigger.
async fn require_cron_routine(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
) -> anyhow::Result<Routine> {
let routine = db
.get_routine_by_name(user_id, name)
.await?
.ok_or_else(|| anyhow::anyhow!("Routine '{}' not found", name))?;
validate_cron_trigger(&routine).map_err(|msg| anyhow::anyhow!("{}", msg))?;
Ok(routine)
}
/// Validate that a routine has a cron trigger. Returns an error message if not.
fn validate_cron_trigger(routine: &Routine) -> Result<(), String> {
if routine.trigger.type_tag() != "cron" {
return Err(format!(
"Routine '{}' has trigger type '{}', not 'cron'. \
Use a different command to manage {} routines.",
routine.name,
routine.trigger.type_tag(),
routine.trigger.type_tag(),
));
}
Ok(())
}
// ── Helpers ─────────────────────────────────────────────────
/// Format a datetime relative to now (e.g. "in 2h", "3m ago").
fn format_relative(dt: DateTime<Utc>) -> String {
let now = Utc::now();
let diff = dt.signed_duration_since(now);
let secs = diff.num_seconds();
if secs.abs() < 60 {
if secs >= 0 {
"in <1m".to_string()
} else {
"<1m ago".to_string()
}
} else if secs.abs() < 3600 {
let mins = secs.abs() / 60;
if secs >= 0 {
format!("in {}m", mins)
} else {
format!("{}m ago", mins)
}
} else if secs.abs() < 86400 {
let hours = secs.abs() / 3600;
if secs >= 0 {
format!("in {}h", hours)
} else {
format!("{}h ago", hours)
}
} else {
let days = secs.abs() / 86400;
if secs >= 0 {
format!("in {}d", days)
} else {
format!("{}d ago", days)
}
}
}
/// Truncate a string to a maximum character length.
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(2)).collect();
format!("{}..", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_relative_future() {
let future = Utc::now() + chrono::Duration::hours(2);
let result = format_relative(future);
assert!(
result.starts_with("in "),
"expected 'in ...' for future time, got: {result}"
);
}
#[test]
fn format_relative_past() {
let past = Utc::now() - chrono::Duration::minutes(30);
let result = format_relative(past);
assert!(
result.ends_with(" ago"),
"expected '... ago' for past time, got: {result}"
);
}
#[test]
fn format_relative_days() {
let far_future = Utc::now() + chrono::Duration::days(3);
let result = format_relative(far_future);
assert!(result.contains('d'), "expected days in: {result}");
}
#[test]
fn truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string() {
let result = truncate("hello world", 7);
assert_eq!(result, "hello..");
}
#[test]
fn truncate_multibyte_safe() {
// Ensure no panic on multi-byte characters.
let cjk = "你好世界测试";
let result = truncate(cjk, 4);
assert!(result.ends_with(".."), "got: {result}");
// Must be valid UTF-8 (would have panicked otherwise).
assert!(result.is_char_boundary(result.len()));
}
/// Helper: build a minimal Routine with the given trigger for testing.
fn make_routine(name: &str, trigger: Trigger) -> Routine {
let now = Utc::now();
Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: String::new(),
user_id: "test".to_string(),
enabled: true,
trigger,
action: RoutineAction::Lightweight {
prompt: "test".to_string(),
context_paths: Vec::new(),
max_tokens: 4096,
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(300),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig {
channel: None,
user: "test".to_string(),
on_attention: true,
on_failure: true,
on_success: false,
},
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: now,
updated_at: now,
}
}
#[test]
fn validate_cron_trigger_accepts_cron() {
let routine = make_routine(
"daily-digest",
Trigger::Cron {
schedule: "0 0 9 * * *".to_string(),
timezone: None,
},
);
assert!(validate_cron_trigger(&routine).is_ok());
}
#[test]
fn validate_cron_trigger_rejects_event() {
let routine = make_routine(
"on-push",
Trigger::Event {
channel: Some("github".to_string()),
pattern: "push".to_string(),
},
);
let err = validate_cron_trigger(&routine).unwrap_err();
assert!(err.contains("event"), "expected 'event' in error: {err}");
assert!(
err.contains("on-push"),
"expected routine name in error: {err}"
);
}
#[test]
fn validate_cron_trigger_rejects_system_event() {
let routine = make_routine(
"on-event",
Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue.opened".to_string(),
filters: std::collections::HashMap::new(),
},
);
let err = validate_cron_trigger(&routine).unwrap_err();
assert!(
err.contains("system_event"),
"expected 'system_event' in error: {err}"
);
}
#[test]
fn validate_cron_trigger_rejects_manual() {
let routine = make_routine("run-once", Trigger::Manual);
let err = validate_cron_trigger(&routine).unwrap_err();
assert!(err.contains("manual"), "expected 'manual' in error: {err}");
}
}
+28
View File
@@ -7,6 +7,7 @@
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
//! - Querying workspace memory (`memory search`, `memory read`, `memory write`)
//! - Managing scheduled routines (`cron list`, `cron create`, `cron edit`, ...)
//! - Managing OS service (`service install`, `service start`, `service stop`)
//! - Listing configured channels (`channels list`)
//! - Active health diagnostics (`doctor`)
@@ -15,6 +16,7 @@
mod channels;
mod completion;
mod config;
mod cron;
mod doctor;
#[cfg(feature = "import")]
pub mod import;
@@ -31,6 +33,7 @@ mod tool;
pub use channels::{ChannelsCommand, run_channels_command};
pub use completion::Completion;
pub use config::{ConfigCommand, run_config_command};
pub use cron::{CronCommand, run_cron_command};
pub use doctor::run_doctor_command;
#[cfg(feature = "import")]
pub use import::{ImportCommand, run_import_command};
@@ -147,6 +150,14 @@ pub enum Command {
)]
Channels(ChannelsCommand),
/// Manage scheduled routines (cron jobs)
#[command(
subcommand,
about = "Manage cron routines",
long_about = "List, create, edit, enable/disable, delete, and view history of cron routines.\nExamples:\n ironclaw cron list\n ironclaw cron create --name daily-digest --schedule '0 0 9 * * *' --prompt 'Summarize today'"
)]
Cron(CronCommand),
/// Manage MCP servers (hosted tool providers)
#[command(
subcommand,
@@ -281,6 +292,23 @@ pub async fn init_secrets_store()
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
/// Run the Cron CLI subcommand.
pub async fn run_cron_cli(
cron_cmd: &CronCommand,
config_path: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let config = crate::config::Config::from_env_with_toml(config_path)
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
let db: Arc<dyn crate::db::Database> = crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
run_cron_command(cron_cmd.clone(), db, &user_id).await
}
/// Run the Memory CLI subcommand.
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
let config = crate::config::Config::from_env()
@@ -13,6 +13,7 @@ Commands:
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
cron Manage cron routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
@@ -13,6 +13,7 @@ Commands:
tool Manage WASM tools
registry Browse/install extensions
channels Manage channels
cron Manage cron routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
@@ -16,6 +16,7 @@ Commands:
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
cron Manage cron routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
@@ -16,6 +16,7 @@ Commands:
tool Manage WASM tools
registry Browse/install extensions
channels Manage channels
cron Manage cron routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
+4
View File
@@ -67,6 +67,10 @@ async fn async_main() -> anyhow::Result<()> {
)
.await;
}
Some(Command::Cron(cron_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_cron_cli(cron_cmd, cli.config.as_deref()).await;
}
Some(Command::Mcp(mcp_cmd)) => {
init_cli_tracing();
return run_mcp_command(*mcp_cmd.clone()).await;