diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index e53929ca..323a5a38 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -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 | diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7efab882..44b6d5c8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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 = 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() diff --git a/src/cli/routines.rs b/src/cli/routines.rs new file mode 100644 index 00000000..305aca63 --- /dev/null +++ b/src/cli/routines.rs @@ -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, + + /// 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, + + /// Cooldown between fires in seconds + #[arg(long, default_value = "300")] + cooldown: u64, + + /// Notification channel + #[arg(long)] + notify_channel: Option, + }, + + /// Edit an existing routine + #[command(alias = "update")] + Edit { + /// Routine name + #[arg(long)] + name: String, + + /// New schedule + #[arg(long)] + schedule: Option, + + /// New prompt + #[arg(long)] + prompt: Option, + + /// New description + #[arg(long)] + description: Option, + + /// New timezone + #[arg(long)] + timezone: Option, + + /// New cooldown in seconds + #[arg(long)] + cooldown: Option, + }, + + /// 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, + 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, + 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 = 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, + user_id: &str, + name: &str, + schedule: &str, + prompt: &str, + description: &str, + timezone: Option<&str>, + cooldown_secs: u64, + notify_channel: Option, +) -> 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, + user_id: &str, + name: &str, + schedule: Option<&str>, + prompt: Option<&str>, + description: Option<&str>, + timezone: Option<&str>, + cooldown: Option, +) -> 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, + 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, + 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, + 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 = 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, + user_id: &str, + name: &str, +) -> anyhow::Result { + 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) -> 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())); + } +} diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output.snap deleted file mode 100644 index 3c941d88..00000000 --- a/src/cli/snapshots/ironclaw__cli__tests__help_output.snap +++ /dev/null @@ -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 Single message mode - send one message and exit - -c, --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 diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap index c1afbc58..c7d8db13 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap @@ -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 diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap deleted file mode 100644 index 28e9cb08..00000000 --- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap +++ /dev/null @@ -1,49 +0,0 @@ ---- -source: src/cli/mod.rs -assertion_line: 318 -expression: help ---- -IronClaw is a secure AI assistant. Use 'ironclaw --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 - Single message mode - send one message and exit - - -c, --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 diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap index 7c821c52..fb4ad231 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap @@ -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 diff --git a/src/main.rs b/src/main.rs index 735b1228..12a8caf6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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;