From 67b2c08a7c2c9c5a3f26c3bd2909691538cdc292 Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:32:10 +0800 Subject: [PATCH] feat(cli): add `logs` command for gateway log access (#1105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `ironclaw logs` to tail gateway.log with reverse-seek (O(output) memory, no full-file load) - Add `--follow` for live SSE streaming from /api/logs/events - Add `--level` to get/set runtime log level via /api/logs/level - Support --json, --plain, --local-time, --url, --token, --timeout flags - Respect --config for gateway address/token resolution (consistent with other CLI commands) - Fail explicitly when --config points to invalid file instead of silent fallback - Wire Logs variant into Command enum and main.rs dispatch - Add 9 unit tests (tail_file chunked read, colorize, timestamp conversion, JSON output) - Update FEATURE_PARITY.md: logs ❌ → 🚧 --- FEATURE_PARITY.md | 2 +- src/cli/logs.rs | 587 ++++++++++++++++++ src/cli/mod.rs | 10 + .../ironclaw__cli__tests__help_output.snap | 36 ++ ...li__tests__help_output_without_import.snap | 1 + ...ronclaw__cli__tests__long_help_output.snap | 52 ++ ...ests__long_help_output_without_import.snap | 1 + src/main.rs | 4 + 8 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 src/cli/logs.rs create mode 100644 src/cli/snapshots/ironclaw__cli__tests__help_output.snap create mode 100644 src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 6e308a10..db4ab92a 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -176,7 +176,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `browser` | ✅ | ❌ | P3 | Browser automation | | `sandbox` | ✅ | ✅ | - | WASM sandbox | | `doctor` | ✅ | 🚧 | P2 | 16 subsystem checks | -| `logs` | ✅ | ❌ | P3 | Query logs | +| `logs` | ✅ | 🚧 | P3 | `logs` (gateway.log tail), `--follow` (SSE live stream), `--level` (get/set). No DB-persisted log history. | | `update` | ✅ | ❌ | P3 | Self-update | | `completion` | ✅ | ✅ | - | Shell completion | | `/subagents spawn` | ✅ | ❌ | P3 | Spawn subagents from chat | diff --git a/src/cli/logs.rs b/src/cli/logs.rs new file mode 100644 index 00000000..651bf891 --- /dev/null +++ b/src/cli/logs.rs @@ -0,0 +1,587 @@ +//! CLI command for viewing and managing gateway logs. +//! +//! Provides access to gateway logs through three mechanisms: +//! - Reading the gateway log file (`~/.ironclaw/gateway.log`) +//! - Streaming live logs via the gateway's SSE endpoint (`/api/logs/events`) +//! - Getting/setting the runtime log level via `/api/logs/level` + +use std::io::{Seek, SeekFrom}; +use std::path::Path; + +use clap::Args; + +/// View and manage gateway logs. +#[derive(Args, Debug, Clone)] +#[command( + about = "View and manage gateway logs", + long_about = "Tail gateway logs, stream live output, or adjust log level.\nExamples:\n ironclaw logs # Show last 200 lines\n ironclaw logs --follow # Stream live logs via SSE\n ironclaw logs --limit 50 --json # Last 50 lines as JSON\n ironclaw logs --level # Show current log level\n ironclaw logs --level debug # Set log level to debug" +)] +pub struct LogsCommand { + /// Stream live logs from the running gateway via SSE. + /// Replays recent history then streams new entries in real time. + #[arg(short, long)] + pub follow: bool, + + /// Maximum number of lines to show (default: 200) + #[arg(short, long, default_value = "200")] + pub limit: usize, + + /// Output log entries as JSON (one object per line) + #[arg(long)] + pub json: bool, + + /// Display timestamps in local timezone + #[arg(long)] + pub local_time: bool, + + /// Plain text output (no ANSI styling) + #[arg(long)] + pub plain: bool, + + /// Gateway URL (default: http://{GATEWAY_HOST}:{GATEWAY_PORT}) + #[arg(long)] + pub url: Option, + + /// Gateway auth token (reads GATEWAY_AUTH_TOKEN env if not set) + #[arg(long)] + pub token: Option, + + /// Connection timeout in milliseconds (default: 5000) + #[arg(long, default_value = "5000")] + pub timeout: u64, + + /// Get or set runtime log level. Without a value, shows current level. + /// With a value (trace|debug|info|warn|error), sets the level. + #[arg(long, num_args = 0..=1, default_missing_value = "")] + pub level: Option, +} + +/// Resolved gateway connection parameters. +struct GatewayParams { + base_url: String, + token: String, +} + +/// Run the logs CLI command. +pub async fn run_logs_command(cmd: LogsCommand, config_path: Option<&Path>) -> anyhow::Result<()> { + // --level takes priority: it's a control-plane operation, not log viewing. + if let Some(level_arg) = &cmd.level { + let params = resolve_gateway_params(&cmd, config_path).await?; + if level_arg.is_empty() { + return cmd_get_level(&cmd, ¶ms).await; + } else { + return cmd_set_level(&cmd, level_arg, ¶ms).await; + } + } + + if cmd.follow { + let params = resolve_gateway_params(&cmd, config_path).await?; + cmd_follow(&cmd, ¶ms).await + } else { + cmd_show(&cmd) + } +} + +// ── Show log file ──────────────────────────────────────────────────────── + +/// Read the last N lines from `~/.ironclaw/gateway.log`. +/// +/// Uses a reverse-scan strategy: seeks to the end of the file and reads +/// backwards in chunks to find the last `limit` newlines, so memory usage +/// is proportional to the output size, not the file size. +fn cmd_show(cmd: &LogsCommand) -> anyhow::Result<()> { + let log_path = crate::bootstrap::ironclaw_base_dir().join("gateway.log"); + if !log_path.exists() { + anyhow::bail!( + "No gateway log file found at {}.\n\ + The log file is created when the gateway runs in background mode \ + (e.g. `ironclaw gateway start`).", + log_path.display() + ); + } + + let lines = tail_file(&log_path, cmd.limit)?; + + if lines.is_empty() { + println!("(log file is empty)"); + return Ok(()); + } + + if cmd.json { + for line in &lines { + let obj = serde_json::json!({ "line": line }); + println!("{}", obj); + } + } else { + for line in &lines { + println!("{}", line); + } + } + + Ok(()) +} + +/// Read the last `n` lines from a file by scanning backwards from EOF. +/// +/// Reads in 8 KiB chunks from the end, counting newlines until enough +/// are found or the beginning of the file is reached. +fn tail_file(path: &Path, n: usize) -> anyhow::Result> { + let mut file = std::fs::File::open(path) + .map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path.display(), e))?; + + let file_len = file + .seek(SeekFrom::End(0)) + .map_err(|e| anyhow::anyhow!("Failed to seek {}: {}", path.display(), e))?; + + if file_len == 0 { + return Ok(Vec::new()); + } + + // Read backwards in chunks to find enough newlines. + const CHUNK_SIZE: u64 = 8192; + let mut tail_bytes = Vec::new(); + let mut newline_count = 0; + let mut remaining = file_len; + + while remaining > 0 && newline_count <= n { + let read_size = std::cmp::min(CHUNK_SIZE, remaining); + remaining -= read_size; + + file.seek(SeekFrom::Start(remaining)) + .map_err(|e| anyhow::anyhow!("Seek failed: {e}"))?; + + let mut chunk = vec![0u8; read_size as usize]; + std::io::Read::read_exact(&mut file, &mut chunk) + .map_err(|e| anyhow::anyhow!("Read failed: {e}"))?; + + // Count newlines in this chunk (backwards). + for &byte in chunk.iter().rev() { + if byte == b'\n' { + newline_count += 1; + } + } + + // Prepend chunk to collected bytes. + chunk.append(&mut tail_bytes); + tail_bytes = chunk; + } + + // Convert to string and take last N lines. + let text = String::from_utf8_lossy(&tail_bytes); + let all_lines: Vec<&str> = text.lines().collect(); + let start = all_lines.len().saturating_sub(n); + + Ok(all_lines[start..].iter().map(|s| s.to_string()).collect()) +} + +// ── Follow (live SSE stream) ───────────────────────────────────────────── + +/// Connect to the gateway's `/api/logs/events` SSE endpoint and stream logs. +async fn cmd_follow(cmd: &LogsCommand, params: &GatewayParams) -> anyhow::Result<()> { + let timeout_dur = std::time::Duration::from_millis(cmd.timeout); + + let client = reqwest::Client::builder() + .connect_timeout(timeout_dur) + .build() + .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; + + let url = format!("{}/api/logs/events", params.base_url); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", params.token)) + .header("Accept", "text/event-stream") + // No per-request timeout: SSE streams are long-lived. + .timeout(std::time::Duration::from_secs(u64::MAX / 2)) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to gateway at {url}: {e}\n\ + Is the gateway running? Try `ironclaw gateway status`." + ) + })?; + + if !resp.status().is_success() { + anyhow::bail!( + "Gateway returned HTTP {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + eprintln!("Connected to {} — streaming logs (Ctrl-C to stop)", url); + + // Parse SSE stream line by line. + let mut bytes_stream = resp.bytes_stream(); + let mut buffer = String::new(); + let mut lines_shown: usize = 0; + + use futures::StreamExt; + while let Some(chunk) = bytes_stream.next().await { + let chunk = chunk.map_err(|e| anyhow::anyhow!("Stream error: {e}"))?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete lines from the buffer. + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + // SSE format: "data: {...}" lines carry the payload. + if let Some(data) = line.strip_prefix("data: ") + && let Ok(entry) = serde_json::from_str::(data) + { + print_log_entry(&entry, cmd); + lines_shown += 1; + } + // Skip "event:", "id:", "retry:", and empty keepalive lines. + } + } + + if lines_shown == 0 { + eprintln!("(no log entries received)"); + } + + Ok(()) +} + +// ── Log level get/set ──────────────────────────────────────────────────── + +/// GET /api/logs/level — show the current log level. +async fn cmd_get_level(cmd: &LogsCommand, params: &GatewayParams) -> anyhow::Result<()> { + let timeout_dur = std::time::Duration::from_millis(cmd.timeout); + + let client = reqwest::Client::builder() + .timeout(timeout_dur) + .build() + .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; + + let url = format!("{}/api/logs/level", params.base_url); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", params.token)) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to gateway at {url}: {e}\n\ + Is the gateway running? Try `ironclaw gateway status`." + ) + })?; + + if !resp.status().is_success() { + anyhow::bail!( + "Gateway returned HTTP {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("Invalid response: {e}"))?; + + if cmd.json { + println!( + "{}", + serde_json::to_string_pretty(&body).unwrap_or_default() + ); + } else { + let level = body + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + println!("Current log level: {}", level); + } + + Ok(()) +} + +/// PUT /api/logs/level — change the runtime log level. +async fn cmd_set_level( + cmd: &LogsCommand, + level: &str, + params: &GatewayParams, +) -> anyhow::Result<()> { + const VALID: &[&str] = &["trace", "debug", "info", "warn", "error"]; + let level_lower = level.to_lowercase(); + if !VALID.contains(&level_lower.as_str()) { + anyhow::bail!( + "Invalid log level '{}'. Must be one of: {}", + level, + VALID.join(", ") + ); + } + + let timeout_dur = std::time::Duration::from_millis(cmd.timeout); + + let client = reqwest::Client::builder() + .timeout(timeout_dur) + .build() + .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; + + let url = format!("{}/api/logs/level", params.base_url); + let resp = client + .put(&url) + .header("Authorization", format!("Bearer {}", params.token)) + .json(&serde_json::json!({ "level": level_lower })) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to gateway at {url}: {e}\n\ + Is the gateway running? Try `ironclaw gateway status`." + ) + })?; + + if !resp.status().is_success() { + anyhow::bail!( + "Gateway returned HTTP {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("Invalid response: {e}"))?; + + if cmd.json { + println!( + "{}", + serde_json::to_string_pretty(&body).unwrap_or_default() + ); + } else { + let new_level = body + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or(&level_lower); + println!("Log level set to: {}", new_level); + } + + Ok(()) +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// Resolve gateway connection params from CLI flags, config file, or env. +/// +/// Priority: --url/--token flags > config TOML > env vars > defaults. +async fn resolve_gateway_params( + cmd: &LogsCommand, + config_path: Option<&Path>, +) -> anyhow::Result { + // Load gateway config. Errors propagate when --config is explicit. + let gw_config = load_gateway_config(config_path).await?; + + // URL: --url flag > config TOML > env vars > defaults. + let base_url = if let Some(url) = &cmd.url { + url.trim_end_matches('/').to_string() + } else if let Some(cfg) = &gw_config { + format!("http://{}:{}", cfg.host, cfg.port) + } else { + let host = std::env::var("GATEWAY_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port: u16 = std::env::var("GATEWAY_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000); + format!("http://{}:{}", host, port) + }; + + // Token: --token flag > config TOML > env var. + let token = if let Some(token) = &cmd.token { + token.clone() + } else if let Some(t) = gw_config.as_ref().and_then(|c| c.auth_token.clone()) { + t + } else { + std::env::var("GATEWAY_AUTH_TOKEN").map_err(|_| { + anyhow::anyhow!( + "No auth token provided. Use --token or set GATEWAY_AUTH_TOKEN.\n\ + The token is printed when the gateway starts." + ) + })? + }; + + Ok(GatewayParams { base_url, token }) +} + +/// Try to load gateway config from the TOML config file. +/// +/// If `config_path` was explicitly provided (via `--config`), errors are +/// propagated — the user asked for a specific file and deserves a clear +/// failure when it is missing, unreadable, or malformed. When no path +/// was given we fall back to env-only resolution and silently return +/// `None` on failure so that `ironclaw logs` works without any config. +async fn load_gateway_config( + config_path: Option<&Path>, +) -> anyhow::Result> { + if config_path.is_some() { + // Explicit --config: propagate errors. + let config = crate::config::Config::from_env_with_toml(config_path) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))?; + Ok(config.channels.gateway) + } else { + // No explicit config: best-effort, swallow errors. + let config = crate::config::Config::from_env_with_toml(None).await.ok(); + Ok(config.and_then(|c| c.channels.gateway)) + } +} + +/// Print a single log entry to stdout. +fn print_log_entry(entry: &serde_json::Value, cmd: &LogsCommand) { + if cmd.json { + println!("{}", serde_json::to_string(entry).unwrap_or_default()); + return; + } + + let level = entry.get("level").and_then(|v| v.as_str()).unwrap_or("?"); + let target = entry.get("target").and_then(|v| v.as_str()).unwrap_or(""); + let message = entry.get("message").and_then(|v| v.as_str()).unwrap_or(""); + let timestamp = entry + .get("timestamp") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let display_ts = if cmd.local_time { + convert_to_local_time(timestamp) + } else { + timestamp.to_string() + }; + + if cmd.plain { + println!("{} {} [{}] {}", display_ts, level, target, message); + } else { + let level_colored = colorize_level(level); + println!("{} {} [{}] {}", display_ts, level_colored, target, message); + } +} + +/// Convert an RFC 3339 timestamp to local time display. +fn convert_to_local_time(ts: &str) -> String { + chrono::DateTime::parse_from_rfc3339(ts) + .map(|dt| { + dt.with_timezone(&chrono::Local) + .format("%Y-%m-%dT%H:%M:%S%.3f") + .to_string() + }) + .unwrap_or_else(|_| ts.to_string()) +} + +/// Apply ANSI color to log level for terminal display. +fn colorize_level(level: &str) -> String { + match level { + "ERROR" => format!("\x1b[31m{}\x1b[0m", level), // red + "WARN" => format!("\x1b[33m{}\x1b[0m", level), // yellow + "INFO" => format!("\x1b[32m{}\x1b[0m", level), // green + "DEBUG" => format!("\x1b[36m{}\x1b[0m", level), // cyan + "TRACE" => format!("\x1b[90m{}\x1b[0m", level), // gray + _ => level.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_colorize_level() { + assert!(colorize_level("ERROR").contains("\x1b[31m")); + assert!(colorize_level("WARN").contains("\x1b[33m")); + assert!(colorize_level("INFO").contains("\x1b[32m")); + assert!(colorize_level("DEBUG").contains("\x1b[36m")); + assert!(colorize_level("TRACE").contains("\x1b[90m")); + assert_eq!(colorize_level("UNKNOWN"), "UNKNOWN"); + } + + #[test] + fn test_convert_to_local_time_valid() { + let ts = "2024-01-15T10:30:00.000Z"; + let result = convert_to_local_time(ts); + assert!(result.contains("2024-01-15")); + } + + #[test] + fn test_convert_to_local_time_invalid() { + let ts = "not-a-timestamp"; + assert_eq!(convert_to_local_time(ts), "not-a-timestamp"); + } + + #[test] + fn test_print_log_entry_json() { + let entry = serde_json::json!({ + "level": "INFO", + "target": "ironclaw::agent", + "message": "test message", + "timestamp": "2024-01-15T10:30:00.000Z" + }); + let cmd = LogsCommand { + follow: false, + limit: 200, + json: true, + local_time: false, + plain: false, + url: None, + token: None, + timeout: 5000, + level: None, + }; + // Should not panic + print_log_entry(&entry, &cmd); + } + + #[test] + fn test_tail_file_small() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + std::fs::write(&path, "line1\nline2\nline3\nline4\nline5\n").unwrap(); + + let result = tail_file(&path, 3).unwrap(); + assert_eq!(result, vec!["line3", "line4", "line5"]); + } + + #[test] + fn test_tail_file_fewer_lines_than_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + std::fs::write(&path, "a\nb\n").unwrap(); + + let result = tail_file(&path, 200).unwrap(); + assert_eq!(result, vec!["a", "b"]); + } + + #[test] + fn test_tail_file_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + std::fs::write(&path, "").unwrap(); + + let result = tail_file(&path, 10).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_tail_file_large() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("big.log"); + // Write 10000 lines to test chunked reading. + let content: String = (0..10000).map(|i| format!("line {}\n", i)).collect(); + std::fs::write(&path, &content).unwrap(); + + let result = tail_file(&path, 5).unwrap(); + assert_eq!(result.len(), 5); + assert_eq!(result[0], "line 9995"); + assert_eq!(result[4], "line 9999"); + } + + #[test] + fn test_tail_file_no_trailing_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + std::fs::write(&path, "line1\nline2\nline3").unwrap(); + + let result = tail_file(&path, 2).unwrap(); + assert_eq!(result, vec!["line2", "line3"]); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 652cac01..cf3c793e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,7 @@ //! - Managing OS service (`service install`, `service start`, `service stop`) //! - Listing configured channels (`channels list`) //! - Active health diagnostics (`doctor`) +//! - Viewing gateway logs (`logs`) //! - Checking system health (`status`) mod channels; @@ -19,6 +20,7 @@ mod config; mod doctor; #[cfg(feature = "import")] pub mod import; +mod logs; mod mcp; pub mod memory; pub mod oauth_defaults; @@ -36,6 +38,7 @@ pub use config::{ConfigCommand, run_config_command}; pub use doctor::run_doctor_command; #[cfg(feature = "import")] pub use import::{ImportCommand, run_import_command}; +pub use logs::{LogsCommand, run_logs_command}; pub use mcp::{McpCommand, run_mcp_command}; pub use memory::MemoryCommand; pub use memory::run_memory_command_with_db; @@ -206,6 +209,13 @@ pub enum Command { )] Doctor, + /// View and manage gateway logs + #[command( + about = "View and manage gateway logs", + long_about = "Tail gateway logs, stream live output, or adjust log level.\nExamples:\n ironclaw logs # Show last 200 lines from gateway.log\n ironclaw logs --follow # Stream live logs via SSE\n ironclaw logs --level # Show current log level\n ironclaw logs --level debug # Set log level to debug" + )] + Logs(LogsCommand), + /// Show system health and diagnostics #[command( about = "Show system status", diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output.snap new file mode 100644 index 00000000..a554acae --- /dev/null +++ b/src/cli/snapshots/ironclaw__cli__tests__help_output.snap @@ -0,0 +1,36 @@ +--- +source: src/cli/mod.rs +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 + channels Manage channels + routines Manage routines + mcp Manage MCP servers + memory Manage workspace memory + pairing Manage DM pairing + service Manage OS service + skills Manage skills + doctor Run diagnostics + logs View and manage gateway logs + 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 c7d8db13..3f3cf4fc 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 @@ -20,6 +20,7 @@ Commands: service Manage OS service skills Manage skills doctor Run diagnostics + logs View and manage gateway logs status Show system status completion Generate completions help Print this message or the help of the given subcommand(s) diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap new file mode 100644 index 00000000..99b3ef53 --- /dev/null +++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap @@ -0,0 +1,52 @@ +--- +source: src/cli/mod.rs +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 + channels Manage channels + routines Manage routines + mcp Manage MCP servers + memory Manage workspace memory + pairing Manage DM pairing + service Manage OS service + skills Manage skills + doctor Run diagnostics + logs View and manage gateway logs + 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 fb4ad231..aa7ae8b0 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 @@ -23,6 +23,7 @@ Commands: service Manage OS service skills Manage skills doctor Run diagnostics + logs View and manage gateway logs status Show system status completion Generate completions help Print this message or the help of the given subcommand(s) diff --git a/src/main.rs b/src/main.rs index a7d95bec..0b469530 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,10 @@ async fn async_main() -> anyhow::Result<()> { return ironclaw::cli::run_skills_command(skills_cmd.clone(), cli.config.as_deref()) .await; } + Some(Command::Logs(logs_cmd)) => { + init_cli_tracing(); + return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await; + } Some(Command::Doctor) => { init_cli_tracing(); return ironclaw::cli::run_doctor_command().await;