mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 07:20:19 +00:00
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:
+1
-1
@@ -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
@@ -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, ¶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<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"]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user