history : historique structuré + am history (issue #7) #22
+21
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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())
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user