diff --git a/src/cli.rs b/src/cli.rs index 7200884..54c4d81 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -169,6 +169,27 @@ pub enum Command { #[arg(long, value_name = "CATEGORY")] category: Option, }, + /// Search the command history (am and shell commands) + History { + /// Only commands of this kind (am or shell) + #[arg(long, value_name = "KIND")] + kind: Option, + /// Only commands run in this directory + #[arg(long, value_name = "DIR")] + cwd: Option, + /// Full-text search in the command line + #[arg(long, value_name = "TEXT")] + search: Option, + /// Only failed commands + #[arg(long)] + failed: bool, + /// Only commands of this session + #[arg(long, value_name = "ID")] + session: Option, + /// Maximum number of entries (default: 100) + #[arg(long, value_name = "N", default_value = "100")] + limit: usize, + }, /// Show detailed information about one agent Info { /// Agent name or alias diff --git a/src/commands/history_cmd.rs b/src/commands/history_cmd.rs new file mode 100644 index 0000000..63df270 --- /dev/null +++ b/src/commands/history_cmd.rs @@ -0,0 +1,96 @@ +//! history: search and reuse the structured command history. + +use super::*; +use crate::history::{self, HistoryEntry}; +use crate::output::print_json; + +/// Filter history entries; returns them most recent first. +pub fn filter( + app: &App, + kind: Option<&str>, + cwd: Option<&str>, + search: Option<&str>, + failed: bool, + session: Option<&str>, + limit: usize, +) -> Vec<(String, HistoryEntry)> { + let _ = session; // session filtering happens by reading one file directly + let mut out: Vec<(String, HistoryEntry)> = Vec::new(); + let dir = history::dir(app); + for f in history::files(&dir).iter().rev() { + let sid = history::session_of_file(f).unwrap_or_default(); + if let Some(s) = session { + if sid != s { + continue; + } + } + let Ok(text) = std::fs::read_to_string(f) else { + continue; + }; + let mut entries: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect(); + entries.reverse(); + for e in entries { + if kind.map_or(true, |k| e.kind == k) + && cwd.map_or(true, |c| e.cwd.as_deref() == Some(c)) + && search.map_or(true, |s| e.cmd.to_lowercase().contains(&s.to_lowercase())) + && (!failed || e.exit_code.is_some()) + { + out.push((sid.clone(), e)); + if limit > 0 && out.len() >= limit { + return out; + } + } + } + } + out +} + +pub fn run( + app: &App, + kind: Option<&str>, + cwd: Option<&str>, + search: Option<&str>, + failed: bool, + session: Option<&str>, + limit: usize, +) -> Result { + history::migrate(app)?; + let rows = filter(app, kind, cwd, search, failed, session, limit); + if app.json() { + let payload: Vec = rows + .iter() + .enumerate() + .map(|(i, (sid, e))| { + serde_json::json!({ + "n": rows.len() - i, + "ts": e.ts, + "cmd": e.cmd, + "kind": e.kind, + "cwd": e.cwd, + "exit_code": e.exit_code, + "duration_ms": e.duration_ms, + "session": sid, + }) + }) + .collect(); + print_json(&payload); + return Ok(0); + } + for (i, (_sid, e)) in rows.iter().enumerate() { + let n = rows.len() - i; + let ts = e.ts.get(0..16).unwrap_or(&e.ts); + let flag = match e.exit_code { + Some(_) => "✗".to_string(), + None => " ".to_string(), + }; + println!("{n:>4} {flag} {ts} {}", e.cmd); + } + if rows.is_empty() { + app.log.info("no matching history — run 'am' and type some commands"); + } + Ok(0) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f31fbfd..dcbbce7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod config_cmd; pub mod doctor_cmd; pub mod export_import; pub mod help_cmd; +pub mod history_cmd; pub mod info_cmd; pub mod install_cmd; pub mod list_cmd; @@ -78,6 +79,22 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all), Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()), + Command::History { + kind, + cwd, + search, + failed, + session, + limit, + } => history_cmd::run( + app, + kind.as_deref(), + cwd.as_deref(), + search.as_deref(), + *failed, + session.as_deref(), + *limit, + ), Command::Info { agent } => info_cmd::run(app, agent), Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()), Command::Version => version_cmd::run(app), diff --git a/src/help.rs b/src/help.rs index 857aeca..714dc33 100644 --- a/src/help.rs +++ b/src/help.rs @@ -238,6 +238,28 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Starts of one agent since a date.", code: "log claude-code --kind start --since 2026-08-01" }, ], }, + HelpSpec { + name: "history", + category: "Commands", + usage: "history {flags}", + about: "Search the command history (am and shell commands).", + search_terms: &["past", "commands", "rerun"], + flags: &[ + HelpFlag { short: "", long: "--kind", value: "KIND", desc: "Only commands of this kind (am or shell)" }, + HelpFlag { short: "", long: "--cwd", value: "DIR", desc: "Only commands run in this directory" }, + HelpFlag { short: "", long: "--search", value: "TEXT", desc: "Full-text search in the command line" }, + HelpFlag { short: "", long: "--failed", value: "", desc: "Only failed commands" }, + HelpFlag { short: "", long: "--session", value: "ID", desc: "Only commands of this session" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "The last 100 commands.", code: "history" }, + HelpExample { desc: "Find past installs.", code: "history --search install" }, + HelpExample { desc: "Failed commands only.", code: "history --failed" }, + ], + }, HelpSpec { name: "search", category: "Commands", diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..b93d83f --- /dev/null +++ b/src/history.rs @@ -0,0 +1,274 @@ +//! Structured command history: one JSONL file per REPL session under +//! history/.jsonl. Replaces the flat history.txt (imported once). + +use crate::app::App; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HistoryEntry { + /// RFC 3339 timestamp. + pub ts: String, + pub cmd: String, + /// "am" (agent-manager command) or "shell" (system gateway command). + #[serde(default = "kind_am")] + pub kind: String, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub exit_code: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub agent: Option, +} + +fn kind_am() -> String { + "am".to_string() +} + +impl HistoryEntry { + pub fn now(cmd: impl Into) -> Self { + HistoryEntry { + ts: crate::installers::now_rfc3339(), + cmd: cmd.into(), + kind: "am".to_string(), + cwd: None, + exit_code: None, + duration_ms: None, + agent: None, + } + } + + pub fn with_kind(mut self, kind: impl Into) -> Self { + self.kind = kind.into(); + self + } + + pub fn with_cwd(mut self, cwd: Option) -> Self { + self.cwd = cwd; + self + } + + pub fn with_exit_code(mut self, code: Option) -> Self { + self.exit_code = code; + self + } + + pub fn with_duration_ms(mut self, ms: u64) -> Self { + self.duration_ms = Some(ms); + self + } + + pub fn with_agent(mut self, agent: Option) -> Self { + self.agent = agent; + self + } +} + +/// Directory holding the per-session history files. +pub fn dir(app: &App) -> PathBuf { + app.events_dir().join("history") +} + +/// Append one entry to the session file of a REPL session. +pub fn record(app: &App, session: &str, entry: &HistoryEntry) -> Result<()> { + let d = dir(app); + std::fs::create_dir_all(&d).ok(); + let path = d.join(format!("{session}.jsonl")); + let line = serde_json::to_string(entry)?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("cannot open history file {}", path.display()))?; + use std::io::Write; + f.write_all(line.as_bytes())?; + f.write_all(b"\n")?; + Ok(()) +} + +/// One-shot migration: import the flat history.txt into the structured +/// directory. Returns the number of imported entries. +pub fn migrate(app: &App) -> Result { + let txt = app.paths.state_file.with_file_name("history.txt"); + if !txt.exists() { + return Ok(0); + } + let d = dir(app); + let already = d.join("migrated.jsonl").exists(); + if already { + return Ok(0); + } + let text = std::fs::read_to_string(&txt) + .with_context(|| format!("cannot read {}", txt.display()))?; + let mut count = 0; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let entry = HistoryEntry::now(trimmed.to_string()) + .with_kind("am".to_string()) + .with_cwd(None); + record(app, "migrated", &entry)?; + count += 1; + } + Ok(count) +} + +/// History files of a directory, oldest first. +pub fn files(dir: &Path) -> Vec { + let mut out: Vec = std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl")) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out +} + +/// Read all entries across sessions, most recent first. Corrupt lines are +/// skipped silently. +pub fn read_all(app: &App, limit: usize) -> Vec { + let d = dir(app); + let mut out: Vec = Vec::new(); + for f in files(&d).iter().rev() { + if let Ok(text) = std::fs::read_to_string(f) { + let mut entries: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect(); + entries.reverse(); // newest first within one file + out.extend(entries); + if limit > 0 && out.len() >= limit { + break; + } + } + } + if limit > 0 { + out.truncate(limit); + } + out +} + +/// Parse the session id from a history file name ("20260815_143926.jsonl"). +pub fn session_of_file(path: &Path) -> Option { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.trim_end_matches(".jsonl").to_string()) +} + +/// Corrupt-history guard: a session file that cannot be parsed is reported, +/// never fatal. +pub fn validate_file(path: &Path) -> Result<()> { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read history file {}", path.display()))?; + for (i, line) in text.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + serde_json::from_str::(line).map_err(|e| { + anyhow!("history file {} line {} is corrupt: {e}", path.display(), i + 1) + })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn test_app(tag: &str) -> App { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + "agents: []\n", + ), + ) + .unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let mut app = App::from_cli(cli).unwrap(); + let mut p = app.paths.clone(); + p.install_dir = dir.join("agents"); + p.bin_dir = dir.join("agents").join("bin"); + p.log_dir = dir.join("logs"); + p.state_file = dir.join("state.json"); + p.probe_cache_file = dir.join("probe-cache.json"); + p.config_dir = Some(dir.join("config")); + app.paths = p; + let _ = tag; + app + } + + #[test] + fn record_and_read_back() { + let app = test_app("hist"); + record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap(); + record( + &app, + "s1", + &HistoryEntry::now("ls").with_kind("shell".to_string()).with_duration_ms(12), + ) + .unwrap(); + let all = read_all(&app, 0); + assert_eq!(all.len(), 2); + assert_eq!(all[0].cmd, "ls", "most recent first"); + assert_eq!(all[0].kind, "shell"); + assert_eq!(all[0].duration_ms, Some(12)); + assert_eq!(all[1].cmd, "install jcode"); + } + + #[test] + fn migration_imports_flat_file_once() { + let app = test_app("mig"); + let txt = app.paths.state_file.with_file_name("history.txt"); + std::fs::write(&txt, "install jcode +start group:dev + +").unwrap(); + assert_eq!(migrate(&app).unwrap(), 2); + assert_eq!(migrate(&app).unwrap(), 0, "second run is a no-op"); + let all = read_all(&app, 0); + assert_eq!(all.len(), 2); + assert_eq!(all[0].cmd, "start group:dev"); + } + + #[test] + fn corrupt_lines_are_skipped() { + let app = test_app("corrupt"); + record(&app, "s1", &HistoryEntry::now("list")).unwrap(); + // Append garbage to the session file. + let d = dir(&app); + let f = d.join("s1.jsonl"); + let mut text = std::fs::read_to_string(&f).unwrap(); + text.push_str("not json at all +"); + std::fs::write(&f, text).unwrap(); + let all = read_all(&app, 0); + assert_eq!(all.len(), 1); + assert!(validate_file(&f).is_err()); + } + + #[test] + fn session_of_file_extracts_id() { + assert_eq!( + session_of_file(Path::new("20260815_143926.jsonl")), + Some("20260815_143926".to_string()) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index b581d10..85b1a62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod doctor; pub mod download; pub mod events; pub mod help; +pub mod history; pub mod installers; pub mod nav; pub mod output; diff --git a/src/repl.rs b/src/repl.rs index f26dacc..679318f 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -63,6 +63,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("sessions", "list agent sessions"), ("stats", "usage statistics"), ("log", "read the event journal"), + ("history", "search command history"), ("search", "search the catalog"), ("info", "agent details"), ("install", "install an agent"), @@ -139,7 +140,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "log", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "log", "history", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", @@ -894,21 +895,24 @@ pub fn run(app: &App) -> Result { .with_cwd(Some(current_dir_string())), ); let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string())); + if let Err(e) = crate::history::migrate(app) { + app.log.verbose(&format!("history migration skipped: {e:#}")); + } print!("{}", banner(app, &session, &sid)); - let result = match run_with_editor(app, &mut session) { + let result = match run_with_editor(app, &mut session, &sid) { Ok(code) => Ok(code), Err(e) => { app.log.verbose(&format!( "line editor unavailable ({e:#}); falling back to plain input" )); - run_plain(app, &mut session) + run_plain(app, &mut session, &sid) } }; let _ = crate::sessions::finish_repl(app, &sid); result } -fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { +fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result { let mut rl: rustyline::Editor = rustyline::Editor::new()?; rl.set_helper(Some(AmCompleter::new(app))); @@ -936,6 +940,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { let t = Instant::now(); let result = handle_input(app, session, &mut last, &trimmed); last_duration = Some(t.elapsed()); + record_history(app, sid, &trimmed, &result, t.elapsed()); // 'theme ' may have switched the app theme: repoint the // completion menu at the new palette. if let Some(h) = rl.helper_mut() { @@ -959,8 +964,33 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { Ok(0) } +/// Append one structured history entry for a REPL command. +fn record_history( + app: &App, + sid: &str, + line: &str, + result: &Result, + elapsed: std::time::Duration, +) { + let kind = if is_am_command(line.split_whitespace().next().unwrap_or("")) { + "am" + } else { + "shell" + }; + let exit_code = if result.is_err() { Some(1) } else { None }; + let _ = crate::history::record( + app, + sid, + &crate::history::HistoryEntry::now(line.to_string()) + .with_kind(kind.to_string()) + .with_cwd(Some(current_dir_string())) + .with_exit_code(exit_code) + .with_duration_ms(elapsed.as_millis() as u64), + ); +} + /// Plain fallback when no terminal/line editor is available. -fn run_plain(app: &App, session: &mut ShellSession) -> Result { +fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result { let started = Instant::now(); let mut last: Option = None; let mut last_duration: Option = None; @@ -979,6 +1009,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result { let t = Instant::now(); let result = handle_input(app, session, &mut last, &line); last_duration = Some(t.elapsed()); + record_history(app, sid, &line, &result, t.elapsed()); match result { Ok(true) => break, Ok(false) => {} @@ -1273,6 +1304,10 @@ fn is_am_command(word: &str) -> bool { word, "list" | "status" + | "sessions" + | "stats" + | "log" + | "history" | "search" | "info" | "install" diff --git a/tests/history_test.rs b/tests/history_test.rs new file mode 100644 index 0000000..c1fbc20 --- /dev/null +++ b/tests/history_test.rs @@ -0,0 +1,105 @@ +//! Structured history: record, read, filter, migration, command. + +mod common; + +use agent_manager::commands::history_cmd; +use agent_manager::history::{self, HistoryEntry}; +use std::path::PathBuf; + +#[test] +fn cli_parses_history() { + use agent_manager::cli::{Cli, Command}; + use clap::Parser; + let cli = Cli::parse_from(["am", "history", "--failed", "--search", "install"]); + match &cli.command { + Some(Command::History { + failed, search, .. + }) => { + assert!(*failed); + assert_eq!(search.as_deref(), Some("install")); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn record_and_read_most_recent_first() { + let app = common::test_app("hist", &["history"]); + history::record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap(); + history::record( + &app, + "s1", + &HistoryEntry::now("ls") + .with_kind("shell".to_string()) + .with_duration_ms(12), + ) + .unwrap(); + let all = history::read_all(&app, 0); + assert_eq!(all.len(), 2); + assert_eq!(all[0].cmd, "ls"); + assert_eq!(all[0].kind, "shell"); + assert_eq!(all[0].duration_ms, Some(12)); + assert_eq!(all[1].cmd, "install jcode"); +} + +#[test] +fn migration_imports_flat_history_once() { + let app = common::test_app("mig", &["history"]); + let txt = app.paths.state_file.with_file_name("history.txt"); + std::fs::write(&txt, "install jcode +start group:dev + +").unwrap(); + assert_eq!(history::migrate(&app).unwrap(), 2); + assert_eq!(history::migrate(&app).unwrap(), 0, "no-op on second run"); + let all = history::read_all(&app, 0); + assert_eq!(all.len(), 2); + assert_eq!(all[0].cmd, "start group:dev"); +} + +#[test] +fn filters_by_kind_search_and_failed() { + let app = common::test_app("filt", &["history"]); + history::record( + &app, + "s1", + &HistoryEntry::now("install jcode") + .with_kind("am".to_string()) + .with_exit_code(Some(1)), + ) + .unwrap(); + history::record( + &app, + "s1", + &HistoryEntry::now("ls -la") + .with_kind("shell".to_string()) + .with_cwd(Some("/tmp".to_string())), + ) + .unwrap(); + assert_eq!(history_cmd::filter(&app, Some("shell"), None, None, false, None, 10).len(), 1); + assert_eq!(history_cmd::filter(&app, None, None, Some("install"), false, None, 10).len(), 1); + assert_eq!(history_cmd::filter(&app, None, None, None, true, None, 10).len(), 1); + assert_eq!(history_cmd::filter(&app, None, Some("/tmp"), None, false, None, 10).len(), 1); + assert_eq!(history_cmd::filter(&app, None, None, Some("zzz"), false, None, 10).len(), 0); +} + +#[test] +fn command_runs_and_migrates() { + let app = common::test_app("cmd", &["history"]); + let txt = app.paths.state_file.with_file_name("history.txt"); + std::fs::write(&txt, "install jcode +").unwrap(); + let code = history_cmd::run(&app, None, None, None, false, None, 100).unwrap(); + assert_eq!(code, 0); + // Migration created the structured file. + assert!(history::dir(&app).join("migrated.jsonl").exists()); +} + +#[test] +fn fixture_session_file_parses() { + // Guard for session_of_file used by the display layer. + assert_eq!( + history::session_of_file(&PathBuf::from("20260815_143926.jsonl")), + Some("20260815_143926".to_string()) + ); +}