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")]
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
Info {
/// 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 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<i32> {
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),
+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" },
],
},
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",
+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 events;
pub mod help;
pub mod history;
pub mod installers;
pub mod nav;
pub mod output;
+40 -5
View File
@@ -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<i32> {
.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<i32> {
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?;
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 result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed());
record_history(app, sid, &trimmed, &result, t.elapsed());
// 'theme <name>' 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<i32> {
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.
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 mut last: Option<DataTable> = 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 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"
+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())
);
}