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

* 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.

* fix(cli): reject invalid cron timezones

* refactor(cli): rename cron subcommand to routines

The system manages all routine types (cron, webhook, event, manual),
not just cron schedules. Rename the CLI subcommand to reflect this:
- `ironclaw cron` -> `ironclaw routines` (with `cron` as hidden alias)
- List shows all routines by default, add --trigger filter
- Remove cron-trigger-only validation
- Simplify require_routine helper (no trigger type check)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: reidliu41 <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-12 12:47:23 -07:00
committed by GitHub
co-authored by reidliu41 Claude Opus 4.6
parent 5dfa666691
commit bcda73c2e0
8 changed files with 766 additions and 83 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 |
+29
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 routines (`routines list`, `routines create`, `routines 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 routines;
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 routines::{RoutinesCommand, run_routines_command};
pub use doctor::run_doctor_command;
#[cfg(feature = "import")]
pub use import::{ImportCommand, run_import_command};
@@ -147,6 +150,15 @@ pub enum Command {
)]
Channels(ChannelsCommand),
/// Manage routines (scheduled, event-driven, webhook, manual)
#[command(
subcommand,
alias = "cron",
about = "Manage routines",
long_about = "List, create, edit, enable/disable, delete, and view history of routines.\nExamples:\n ironclaw routines list\n ironclaw routines create --name daily-digest --schedule '0 0 9 * * *' --prompt 'Summarize today'"
)]
Routines(RoutinesCommand),
/// Manage MCP servers (hosted tool providers)
#[command(
subcommand,
@@ -281,6 +293,23 @@ pub async fn init_secrets_store()
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
/// Run the Routines CLI subcommand.
pub async fn run_routines_cli(
routines_cmd: &RoutinesCommand,
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_routines_command(routines_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()
+730
View File
@@ -0,0 +1,730 @@
//! `ironclaw routines` — manage scheduled routines from the CLI.
//!
//! Provides subcommands for listing, creating, editing, enabling/disabling,
//! deleting, and viewing run history of 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;
/// Routines subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum RoutinesCommand {
/// List routines
List {
/// Filter by trigger type (e.g. "cron", "webhook", "event")
#[arg(long)]
trigger: Option<String>,
/// 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 routines CLI command against the database.
pub async fn run_routines_command(
cmd: RoutinesCommand,
db: Arc<dyn Database>,
user_id: &str,
) -> anyhow::Result<()> {
match cmd {
RoutinesCommand::List {
trigger,
disabled,
json,
} => list(&db, user_id, trigger.as_deref(), disabled, json).await,
RoutinesCommand::Create {
name,
schedule,
prompt,
description,
timezone,
cooldown,
notify_channel,
} => {
create(
&db,
user_id,
&name,
&schedule,
&prompt,
&description,
timezone.as_deref(),
cooldown,
notify_channel,
)
.await
}
RoutinesCommand::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
}
RoutinesCommand::Enable { name } => set_enabled(&db, user_id, &name, true).await,
RoutinesCommand::Disable { name } => set_enabled(&db, user_id, &name, false).await,
RoutinesCommand::Delete { name, yes } => delete(&db, user_id, &name, yes).await,
RoutinesCommand::History { name, limit, json } => {
history(&db, user_id, &name, limit, json).await
}
}
}
// ── List ────────────────────────────────────────────────────
async fn list(
db: &Arc<dyn Database>,
user_id: &str,
trigger_filter: Option<&str>,
show_disabled: bool,
json: bool,
) -> anyhow::Result<()> {
let routines = db.list_routines(user_id).await?;
let filtered: Vec<&Routine> = routines
.iter()
.filter(|r| {
trigger_filter
.map(|t| r.trigger.type_tag() == t)
.unwrap_or(true)
})
.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() {
if let Some(t) = trigger_filter {
println!("No {t} routines found.");
} else {
println!("No routines found.");
}
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_timezone_arg(timezone)?;
// 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_routine(db, user_id, name).await?;
validate_timezone_arg(timezone)?;
let mut changed = false;
// Update schedule if provided (only valid for cron routines).
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_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_routine(db, user_id, name).await?;
if !skip_confirm {
println!("Routine: {}", routine.name);
println!(" ID: {}", routine.id);
println!(" Trigger: {}", routine.trigger.type_tag());
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_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.
async fn require_routine(
db: &Arc<dyn Database>,
user_id: &str,
name: &str,
) -> anyhow::Result<Routine> {
db.get_routine_by_name(user_id, name)
.await?
.ok_or_else(|| anyhow::anyhow!("Routine '{}' not found", name))
}
fn validate_timezone_arg(timezone: Option<&str>) -> anyhow::Result<()> {
if let Some(tz) = timezone
&& crate::timezone::parse_timezone(tz).is_none()
{
anyhow::bail!("Invalid timezone: '{tz}' is not a valid IANA timezone");
}
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()));
}
}
@@ -1,33 +0,0 @@
---
source: src/cli/mod.rs
assertion_line: 302
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
doctor Run diagnostics
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
--cli-only Run in interactive CLI mode only (disable other channels)
--no-db Skip database connection (for testing)
-m, --message <MESSAGE> Single message mode - send one message and exit
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
--no-onboard Skip first-run onboarding check
-h, --help Print help (see more with '--help')
-V, --version Print version
@@ -13,6 +13,7 @@ Commands:
tool Manage WASM tools
registry Browse/install extensions
channels Manage channels
routines Manage routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
@@ -1,49 +0,0 @@
---
source: src/cli/mod.rs
assertion_line: 318
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
Examples:
ironclaw run # Start the agent
ironclaw config list # List configs
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
doctor Run diagnostics
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
--cli-only
Run in interactive CLI mode only (disable other channels)
--no-db
Skip database connection (for testing)
-m, --message <MESSAGE>
Single message mode - send one message and exit
-c, --config <CONFIG>
Configuration file path (optional, uses env vars by default)
--no-onboard
Skip first-run onboarding check
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
@@ -16,6 +16,7 @@ Commands:
tool Manage WASM tools
registry Browse/install extensions
channels Manage channels
routines Manage 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::Routines(routines_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_routines_cli(routines_cmd, cli.config.as_deref()).await;
}
Some(Command::Mcp(mcp_cmd)) => {
init_cli_tracing();
return run_mcp_command(*mcp_cmd.clone()).await;