history : historique structuré + am history (issue #7) #22

Merged
bruno merged 1 commits from feat/history into main 2026-08-16 22:35:02 -04:00
8 changed files with 576 additions and 5 deletions
Showing only changes of commit c9e2543f50 - Show all commits
+21
View File
@@ -169,6 +169,27 @@ pub enum Command {
#[arg(long, value_name = "CATEGORY")] #[arg(long, value_name = "CATEGORY")]
category: Option<String>, category: Option<String>,
}, },
/// Search the command history (am and shell commands)
History {
/// Only commands of this kind (am or shell)
#[arg(long, value_name = "KIND")]
kind: Option<String>,
/// Only commands run in this directory
#[arg(long, value_name = "DIR")]
cwd: Option<String>,
/// Full-text search in the command line
#[arg(long, value_name = "TEXT")]
search: Option<String>,
/// Only failed commands
#[arg(long)]
failed: bool,
/// Only commands of this session
#[arg(long, value_name = "ID")]
session: Option<String>,
/// Maximum number of entries (default: 100)
#[arg(long, value_name = "N", default_value = "100")]
limit: usize,
},
/// Show detailed information about one agent /// Show detailed information about one agent
Info { Info {
/// Agent name or alias /// Agent name or alias
+96
View File
@@ -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<HistoryEntry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(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<i32> {
history::migrate(app)?;
let rows = filter(app, kind, cwd, search, failed, session, limit);
if app.json() {
let payload: Vec<serde_json::Value> = 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)
}
+17
View File
@@ -5,6 +5,7 @@ pub mod config_cmd;
pub mod doctor_cmd; pub mod doctor_cmd;
pub mod export_import; pub mod export_import;
pub mod help_cmd; pub mod help_cmd;
pub mod history_cmd;
pub mod info_cmd; pub mod info_cmd;
pub mod install_cmd; pub mod install_cmd;
pub mod list_cmd; pub mod list_cmd;
@@ -78,6 +79,22 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all), 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::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::Info { agent } => info_cmd::run(app, agent),
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()), Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app), Command::Version => version_cmd::run(app),
+22
View File
@@ -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" }, 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 { HelpSpec {
name: "search", name: "search",
category: "Commands", category: "Commands",
+274
View File
@@ -0,0 +1,274 @@
//! Structured command history: one JSONL file per REPL session under
//! history/<session>.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<String>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub agent: Option<String>,
}
fn kind_am() -> String {
"am".to_string()
}
impl HistoryEntry {
pub fn now(cmd: impl Into<String>) -> 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<String>) -> Self {
self.kind = kind.into();
self
}
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
pub fn with_exit_code(mut self, code: Option<i32>) -> 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<String>) -> 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<usize> {
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<PathBuf> {
let mut out: Vec<PathBuf> = 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<HistoryEntry> {
let d = dir(app);
let mut out: Vec<HistoryEntry> = Vec::new();
for f in files(&d).iter().rev() {
if let Ok(text) = std::fs::read_to_string(f) {
let mut entries: Vec<HistoryEntry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(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<String> {
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::<HistoryEntry>(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())
);
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod doctor;
pub mod download; pub mod download;
pub mod events; pub mod events;
pub mod help; pub mod help;
pub mod history;
pub mod installers; pub mod installers;
pub mod nav; pub mod nav;
pub mod output; pub mod output;
+40 -5
View File
@@ -63,6 +63,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("sessions", "list agent sessions"), ("sessions", "list agent sessions"),
("stats", "usage statistics"), ("stats", "usage statistics"),
("log", "read the event journal"), ("log", "read the event journal"),
("history", "search command history"),
("search", "search the catalog"), ("search", "search the catalog"),
("info", "agent details"), ("info", "agent details"),
("install", "install an agent"), ("install", "install an agent"),
@@ -139,7 +140,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ 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", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -894,21 +895,24 @@ pub fn run(app: &App) -> Result<i32> {
.with_cwd(Some(current_dir_string())), .with_cwd(Some(current_dir_string())),
); );
let _ = crate::sessions::start_repl(app, &sid, 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)); 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), Ok(code) => Ok(code),
Err(e) => { Err(e) => {
app.log.verbose(&format!( app.log.verbose(&format!(
"line editor unavailable ({e:#}); falling back to plain input" "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); let _ = crate::sessions::finish_repl(app, &sid);
result result
} }
fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> { fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> = let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?; rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app))); rl.set_helper(Some(AmCompleter::new(app)));
@@ -936,6 +940,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
let t = Instant::now(); let t = Instant::now();
let result = handle_input(app, session, &mut last, &trimmed); let result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed()); last_duration = Some(t.elapsed());
record_history(app, sid, &trimmed, &result, t.elapsed());
// 'theme <name>' may have switched the app theme: repoint the // 'theme <name>' may have switched the app theme: repoint the
// completion menu at the new palette. // completion menu at the new palette.
if let Some(h) = rl.helper_mut() { if let Some(h) = rl.helper_mut() {
@@ -959,8 +964,33 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
Ok(0) Ok(0)
} }
/// Append one structured history entry for a REPL command.
fn record_history(
app: &App,
sid: &str,
line: &str,
result: &Result<bool>,
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. /// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> { fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let started = Instant::now(); let started = Instant::now();
let mut last: Option<DataTable> = None; let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None; let mut last_duration: Option<Duration> = None;
@@ -979,6 +1009,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
let t = Instant::now(); let t = Instant::now();
let result = handle_input(app, session, &mut last, &line); let result = handle_input(app, session, &mut last, &line);
last_duration = Some(t.elapsed()); last_duration = Some(t.elapsed());
record_history(app, sid, &line, &result, t.elapsed());
match result { match result {
Ok(true) => break, Ok(true) => break,
Ok(false) => {} Ok(false) => {}
@@ -1273,6 +1304,10 @@ fn is_am_command(word: &str) -> bool {
word, word,
"list" "list"
| "status" | "status"
| "sessions"
| "stats"
| "log"
| "history"
| "search" | "search"
| "info" | "info"
| "install" | "install"
+105
View File
@@ -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())
);
}