Merge pull request 'top + report + log --follow (issues #37 #38)' (#85) from feat/top-report into main

This commit was merged in pull request #85.
This commit is contained in:
2026-08-17 09:03:58 -04:00
7 changed files with 331 additions and 2 deletions
+21
View File
@@ -162,6 +162,24 @@ pub enum Command {
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
},
/// 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<String>,
},
/// 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<PathBuf>,
},
/// 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 {
+40
View File
@@ -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<i32> {
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<chrono::DateTime<chrono::FixedOffset>> {
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<i32> {
if follow {
return follow_loop(app, agent, kinds, since);
}
let events = filter_events(app, agent, kinds, since, limit)?;
if app.json() {
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
+8 -1
View File
@@ -78,12 +78,19 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
} => 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,
+116
View File
@@ -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<Vec<AgentStats>> {
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<i32> {
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<i32>
Ok(0)
}
/// am top : the most used agents (issue #37).
pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
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<i32> {
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 {
+35
View File
@@ -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",
+14 -1
View File
@@ -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"
+97
View File
@@ -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);
}