feat(cli): add logs command for gateway log access (#1105)

- 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 🚧
This commit is contained in:
Reid
2026-03-15 05:32:10 +00:00
committed by GitHub
parent 97b11ffd10
commit 67b2c08a7c
8 changed files with 692 additions and 1 deletions
+1 -1
View File
@@ -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 |
+587
View File
@@ -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<String>,
/// Gateway auth token (reads GATEWAY_AUTH_TOKEN env if not set)
#[arg(long)]
pub token: Option<String>,
/// 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<String>,
}
/// 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, &params).await;
} else {
return cmd_set_level(&cmd, level_arg, &params).await;
}
}
if cmd.follow {
let params = resolve_gateway_params(&cmd, config_path).await?;
cmd_follow(&cmd, &params).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<Vec<String>> {
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::<serde_json::Value>(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<GatewayParams> {
// 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 <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<Option<crate::config::GatewayConfig>> {
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"]);
}
}
+10
View File
@@ -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",
@@ -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 <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
@@ -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)
@@ -0,0 +1,52 @@
---
source: src/cli/mod.rs
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
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 <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
@@ -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)
+4
View File
@@ -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;