diff --git a/src/cli.rs b/src/cli.rs index 24ef07a..175df89 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -162,6 +162,24 @@ pub enum Command { #[arg(long, value_name = "PERIOD")] period: Option, }, + /// Show the most used agents (top 10) + Top { + /// Only events of the last period (7d, 30d, 90d, all) + #[arg(long, value_name = "PERIOD")] + period: Option, + }, + /// Write a markdown activity report + Report { + /// Cover the last 7 days + #[arg(long, conflicts_with = "last_month")] + last_week: bool, + /// Cover the last 30 days + #[arg(long)] + last_month: bool, + /// Output file (default: report-YYYYMMDD.md in the state directory) + #[arg(long, value_name = "FILE")] + output: Option, + }, /// Show which agent works on which project Projects { /// Project name (omit to list every project) @@ -194,6 +212,9 @@ pub enum Command { /// Maximum number of events (default: 50) #[arg(long, value_name = "N", default_value = "50")] limit: usize, + /// Follow the journal as new events arrive + #[arg(long)] + follow: bool, }, /// Install an agent and its dependencies Install { diff --git a/src/commands/log_cmd.rs b/src/commands/log_cmd.rs index 7a00fc4..d4fc91c 100644 --- a/src/commands/log_cmd.rs +++ b/src/commands/log_cmd.rs @@ -35,6 +35,34 @@ pub fn filter_events( Ok(events) } +/// Live view: print matching events as they arrive (issue #38). +fn follow_loop(app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>) -> Result { + app.log.info("following the journal (Ctrl-C to stop)"); + let mut seen: usize = 0; + loop { + let events = filter_events(app, agent, kinds, since, 200)?; + let mut rendered = String::new(); + for e in events.iter().rev() { + rendered.push_str(&e.ts); + rendered.push(' '); + rendered.push_str(e.kind.as_str()); + if let Some(a) = &e.agent { + rendered.push(' '); + rendered.push_str(a); + } + rendered.push('\n'); + } + let bytes = rendered.as_bytes(); + if bytes.len() > seen { + print!("{}", text_after(&rendered, seen)); + use std::io::Write; + let _ = std::io::stdout().flush(); + } + seen = bytes.len(); + std::thread::sleep(std::time::Duration::from_secs(1)); + } +} + pub fn parse_since(s: &str) -> Result> { if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) { return Ok(t); @@ -71,13 +99,25 @@ impl From<&Event> for LogRow { } } +/// Chars of text after byte offset seen (UTF-8 safe). +pub fn text_after(text: &str, seen: usize) -> String { + text.char_indices() + .filter(|(i, _)| *i >= seen) + .map(|(_, c)| c) + .collect() +} + pub fn run( app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>, limit: usize, + follow: bool, ) -> Result { + if follow { + return follow_loop(app, agent, kinds, since); + } let events = filter_events(app, agent, kinds, since, limit)?; if app.json() { let rows: Vec = events.iter().map(LogRow::from).collect(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9b55cd5..4d6dec1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -78,12 +78,19 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { } => run_cmd::restart(app, start, *force, *timeout), Command::Status { agent } => status_cmd::run(app, agent.as_deref()), Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()), + Command::Top { period } => stats_cmd::run_top(app, period.as_deref()), + Command::Report { + last_week, + last_month, + output, + } => stats_cmd::run_report(app, *last_week, *last_month, output.as_deref()), Command::Log { agent, kind, since, limit, - } => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit), + follow, + } => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit, *follow), Command::Projects { name } => projects_cmd::run(app, name.as_deref()), Command::Timeline { agent, diff --git a/src/commands/stats_cmd.rs b/src/commands/stats_cmd.rs index 0a04eae..5b1fd0a 100644 --- a/src/commands/stats_cmd.rs +++ b/src/commands/stats_cmd.rs @@ -3,6 +3,7 @@ use super::*; use crate::events::{self, EventKind}; use crate::output::{print_json, Table}; +use anyhow::Context; use serde::Serialize; use std::collections::BTreeMap; @@ -79,6 +80,61 @@ pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result Ok(map.into_values().collect()) } +/// Top 10 agents by launch count (issue #37). +pub fn top(app: &App, period: Option<&str>) -> Result> { + let mut rows = aggregate(app, None, period)?; + rows.sort_by(|a, b| b.launches.cmp(&a.launches).then(b.total_duration_s.cmp(&a.total_duration_s))); + rows.truncate(10); + Ok(rows) +} + +/// Markdown weekly/monthly report over the last N days (issue #37). +pub fn report_md(app: &App, days: u32) -> String { + let cut = chrono::Utc::now() - chrono::Duration::days(days as i64); + let events = events::read_events(&app.events_dir(), 0); + let mut total_launches = 0u64; + let mut failures = 0u64; + let mut changes = 0u64; + let mut duration = 0u64; + for ev in events { + let ok = chrono::DateTime::parse_from_rfc3339(&ev.ts) + .map(|t| t.with_timezone(&chrono::Utc) >= cut) + .unwrap_or(false); + if !ok { + continue; + } + match ev.kind { + EventKind::Start | EventKind::Run => total_launches += 1, + EventKind::Install | EventKind::Update | EventKind::Uninstall => changes += 1, + _ => {} + } + if let Some(d) = ev.duration_s { + duration += d; + } + if let Some(c) = ev.exit_code { + if c != 0 { + failures += 1; + } + } + } + let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0); + let top_rows = top(app, None).unwrap_or_default(); + let mut md = format!( + "# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n", + duration + ); + if !top_rows.is_empty() { + md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n"); + for r in &top_rows { + md.push_str(&format!( + "| {} | {} | {}s | {} |\n", + r.agent, r.launches, r.total_duration_s, r.failures + )); + } + } + md +} + pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result { let rows = aggregate(app, agent, period)?; if app.json() { @@ -114,6 +170,66 @@ pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result Ok(0) } +/// am top : the most used agents (issue #37). +pub fn run_top(app: &App, period: Option<&str>) -> Result { + let rows = top(app, period)?; + if app.json() { + print_json(&rows); + return Ok(0); + } + let color = app.color(); + let theme = app.theme(); + if rows.is_empty() { + app.log.info("no usage recorded yet"); + return Ok(0); + } + let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1); + let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "FAIL"]); + for r in &rows { + table.row(vec![ + r.agent.clone(), + format!("{} {}", r.launches, bar(r.launches, max_launches, 8)), + format_duration(r.total_duration_s), + if r.failures > 0 { + crate::output::status_style("error", theme, color) + } else { + r.failures.to_string() + }, + ]); + } + print!("{}", table.render()); + Ok(0) +} + +/// am report : write the markdown digest (issue #37). +pub fn run_report( + app: &App, + last_week: bool, + last_month: bool, + output: Option<&std::path::Path>, +) -> Result { + let days: u32 = if last_week { 7 } else { 30 }; + let md = report_md(app, days); + let path = match output { + Some(p) => p.to_path_buf(), + None => { + let stamp = chrono::Local::now().format("%Y%m%d"); + app.events_dir().join(format!("report-{stamp}.md")) + } + }; + if app.dry_run() { + app.log.dry(format!("would write {}", path.display())); + return Ok(0); + } + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&path, md) + .with_context(|| format!("cannot write {}", path.display()))?; + app.log.success(&format!("report written to {}", path.display())); + Ok(0) +} + /// ASCII bar: relative weight of v against max. fn bar(v: u64, max: u64, width: usize) -> String { if max == 0 { diff --git a/src/help.rs b/src/help.rs index f0fb766..f7fd75f 100644 --- a/src/help.rs +++ b/src/help.rs @@ -217,6 +217,41 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, ], }, + HelpSpec { + name: "top", + category: "Commands", + usage: "top {flags}", + about: "Show the most used agents (top 10).", + search_terms: &["ranking", "most", "usage"], + flags: &[ + HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "The most used agents.", code: "top" }, + ], + }, + HelpSpec { + name: "report", + category: "Commands", + usage: "report {flags}", + about: "Write a markdown activity report.", + search_terms: &["digest", "weekly", "monthly"], + flags: &[ + HelpFlag { short: "", long: "--last-week", value: "", desc: "Cover the last 7 days" }, + HelpFlag { short: "", long: "--last-month", value: "", desc: "Cover the last 30 days" }, + HelpFlag { short: "", long: "--output", value: "FILE", desc: "Output file (default: report-YYYYMMDD.md in the state directory)" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Weekly digest.", code: "report --last-week" }, + HelpExample { desc: "Monthly digest to a file.", code: "report --last-month --output ~/rapport-am.md" }, + ], + }, HelpSpec { name: "projects", category: "Commands", diff --git a/src/repl.rs b/src/repl.rs index a77f6b2..f7e5e03 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -62,6 +62,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("status", "show agent state"), ("sessions", "list agent sessions"), ("stats", "usage statistics"), + ("top", "most used agents"), + ("report", "activity report"), ("projects", "project activity"), ("timeline", "unified activity view"), ("log", "read the event journal"), @@ -147,7 +149,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "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", @@ -1265,6 +1267,14 @@ fn handle_line( agent: rest.first().cloned(), period: opt_value("--period"), }, + "top" => Command::Top { + period: opt_value("--period"), + }, + "report" => Command::Report { + last_week: flag("--last-week"), + last_month: flag("--last-month"), + output: opt_value("--output").map(PathBuf::from), + }, "projects" => Command::Projects { name: rest.first().cloned(), }, @@ -1279,6 +1289,7 @@ fn handle_line( kind: opt_value("--kind"), since: opt_value("--since"), limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50), + follow: flag("--follow"), }, "history" => Command::History { kind: opt_value("--kind"), @@ -1397,6 +1408,8 @@ fn is_am_command(word: &str) -> bool { | "status" | "sessions" | "stats" + | "top" + | "report" | "projects" | "timeline" | "log" diff --git a/tests/top_report_test.rs b/tests/top_report_test.rs new file mode 100644 index 0000000..70294c8 --- /dev/null +++ b/tests/top_report_test.rs @@ -0,0 +1,97 @@ +//! top, report and log --follow (issues #37 #38). + +mod common; + +use agent_manager::commands::stats_cmd; +use agent_manager::events; + +fn app_with_events(tag: &str, lines: &[&str]) -> agent_manager::app::App { + let app = common::test_app(tag, &["stats"]); + let month = chrono::Utc::now().format("%Y%m").to_string(); + let path = app.events_dir().join(format!("events-{month}.jsonl")); + std::fs::create_dir_all(app.events_dir()).unwrap(); + let mut text = String::new(); + for l in lines { + text.push_str(l); + text.push('\n'); + } + std::fs::write(&path, text).unwrap(); + app +} + +#[test] +fn top_sorts_by_launches() { + let app = app_with_events( + "top", + &[ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#, + r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#, + r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":0}"#, + ], + ); + let rows = stats_cmd::top(&app, None).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].agent, "a"); + assert_eq!(rows[0].launches, 2); +} + +#[test] +fn report_md_contains_numbers_and_table() { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let lines = [ + format!(r#"{{"ts":"{today}T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}}"#), + format!(r#"{{"ts":"{today}T10:01:00Z","kind":"run","agent":"a","exit_code":1,"duration_s":5}}"#), + format!(r#"{{"ts":"{today}T10:02:00Z","kind":"install","agent":"b"}}"#), + ]; + let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + let app = app_with_events("report", &refs); + let md = stats_cmd::report_md(&app, 7); + assert!(md.contains("# Rapport agent-manager"), "md: {md}"); + assert!(md.contains("Lancements : 2")); + assert!(md.contains("Échecs : 1")); + assert!(md.contains("Changements")); + assert!(md.contains("| a | 2 |"), "table top: {md}"); +} + +#[test] +fn report_command_writes_file() { + let app = app_with_events("reportcmd", &[]); + let out = app.events_dir().join("report-test.md"); + let code = stats_cmd::run_report(&app, true, false, Some(&out)).unwrap(); + assert_eq!(code, 0); + assert!(out.exists()); + let _ = std::fs::remove_file(&out); +} + +#[test] +fn text_after_is_utf8_safe() { + use agent_manager::commands::log_cmd; + let text = "héllo wörld +second line +"; + assert_eq!(log_cmd::text_after(text, 0), text); + assert_eq!(log_cmd::text_after(text, 3), "llo wörld +second line +"); + let full = text.len(); + assert_eq!(log_cmd::text_after(text, full), ""); +} + +#[test] +fn follow_flag_parses() { + use agent_manager::cli::{Cli, Command}; + use clap::Parser; + let cli = Cli::parse_from(["am", "log", "--follow"]); + match &cli.command { + Some(Command::Log { follow, .. }) => assert!(*follow), + other => panic!("unexpected: {other:?}"), + } +} + +#[test] +fn journal_still_reads_after_events_api() { + let app = app_with_events("api", &[]); + let mut log = events::EventLog::new(app.events_dir()); + log.append(&events::Event::now(events::EventKind::Run).with_agent("x")).unwrap(); + assert_eq!(events::read_events(&app.events_dir(), 0).len(), 1); +}