diff --git a/src/cli.rs b/src/cli.rs index 8911e8e..c740ac3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -145,6 +145,11 @@ pub enum Command { #[arg(long, value_name = "PERIOD")] period: Option, }, + /// Show which agent works on which project + Projects { + /// Project name (omit to list every project) + name: Option, + }, /// Read the event journal: everything am did Log { /// Only events of this agent diff --git a/src/commands/export_import.rs b/src/commands/export_import.rs index 82ea130..3395c41 100644 --- a/src/commands/export_import.rs +++ b/src/commands/export_import.rs @@ -77,6 +77,7 @@ pub fn import_cmd(app: &App, file: &Path) -> Result { groups: Default::default(), include: vec![], agents: export.config.agents.clone(), + projects: Default::default(), }; std::fs::write(&dest, serde_yaml::to_string(&cfg)?) .with_context(|| format!("cannot write {}", dest.display()))?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0ba350d..37c7e90 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod list_cmd; pub mod log_cmd; pub mod logs_cmd; pub mod open_cmd; +pub mod projects_cmd; pub mod run_cmd; pub mod search_cmd; pub mod self_uninstall; @@ -75,6 +76,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { since, limit, } => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit), + Command::Projects { name } => projects_cmd::run(app, name.as_deref()), Command::Sessions { agent, project, diff --git a/src/commands/projects_cmd.rs b/src/commands/projects_cmd.rs new file mode 100644 index 0000000..72e50c0 --- /dev/null +++ b/src/commands/projects_cmd.rs @@ -0,0 +1,92 @@ +//! projects: which agent works on which project. + +use super::*; +use crate::output::{print_json, Table}; +use crate::projects::{self, ProjectSummary}; + +pub fn run(app: &App, name: Option<&str>) -> Result { + let summaries = projects::aggregate(app); + if let Some(n) = name { + return show_one(app, n, &summaries); + } + if app.json() { + print_json(&summaries); + return Ok(0); + } + let mut table = Table::new(vec!["PROJECT", "AGENTS", "SESSIONS", "DURATION", "LAST"]); + for s in &summaries { + table.row(vec![ + short_path(&s.root), + s.dominant + .clone() + .unwrap_or_else(|| "—".to_string()), + s.sessions.to_string(), + format_duration(s.total_duration_s), + s.last_activity + .as_deref() + .map(|t| t[..t.len().min(10)].to_string()) + .unwrap_or_else(|| "—".to_string()), + ]); + } + print!("{}", table.render()); + if summaries.is_empty() { + app.log.info("no project activity recorded yet — run or start an agent inside a git repository"); + } + Ok(0) +} + +fn show_one(app: &App, name: &str, summaries: &[ProjectSummary]) -> Result { + let s = summaries + .iter() + .find(|s| { + s.root == name + || s.root.ends_with(&format!("/{name}")) + || s.root.ends_with(&format!("\\{name}")) + }) + .ok_or_else(|| anyhow!("unknown project '{name}' — 'am projects' lists them"))?; + if app.json() { + print_json(s); + return Ok(0); + } + println!("project {}", s.root); + println!(" agents: {}", s.agents.join(", ")); + println!( + " dominant: {}", + s.dominant.as_deref().unwrap_or("—") + ); + println!(" sessions: {}", s.sessions); + println!(" launches: {}", s.launches); + println!(" duration: {}", format_duration(s.total_duration_s)); + println!( + " last: {}", + s.last_activity.as_deref().unwrap_or("—") + ); + Ok(0) +} + +/// Keep the tail of a long path. +fn short_path(p: &str) -> String { + let s = p.trim_end_matches(['/', '\\']); + let name = s.rsplit(['/', '\\']).next().unwrap_or(s); + let mut out = format!("…/{name}"); + let full = p.chars().count(); + let shown = out.chars().count(); + if shown >= full || full <= 30 { + return p.to_string(); + } + out.truncate(30); + out +} + +fn format_duration(secs: u64) -> String { + if secs == 0 { + return "—".to_string(); + } + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } +} diff --git a/src/config.rs b/src/config.rs index 1896f61..50aff7c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,9 @@ pub struct Config { pub include: Vec, #[serde(default)] pub agents: Vec, + /// Per-project profiles: root, default agent, env and hooks. + #[serde(default)] + pub projects: BTreeMap, } fn default_version() -> String { @@ -72,6 +75,23 @@ pub struct Settings { pub theme: Option, } +/// A project profile: everything am needs to know about one project. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ProjectProfile { + /// Root directory of the project (accepts ~ and env variables). + pub root: String, + /// Agent launched by 'am start' without arguments inside the project. + #[serde(default)] + pub default_agent: Option, + /// Environment variables injected when launching the default agent. + #[serde(default)] + pub env: BTreeMap, + /// Hooks: on_start / on_stop / on_install / on_update command lines. + #[serde(default)] + pub hooks: BTreeMap>, +} + impl Settings { pub fn auto_install_deps(&self) -> bool { self.auto_install_deps.unwrap_or(true) @@ -490,6 +510,9 @@ pub fn merge(base: &mut Config, overlay: Config) { base.agents.push(agent); } } + for (k, v) in overlay.projects { + base.projects.insert(k, v); + } } /// Validate a merged config. Returns a list of human-readable problems. diff --git a/src/doctor.rs b/src/doctor.rs index 09704b6..062a51b 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -248,6 +248,13 @@ pub fn fix(app: &App) -> Result> { repaired.push(format!("created {}", app.paths.install_dir.display())); repaired.push(format!("created {}", app.paths.log_dir.display())); + // Project cache: always refreshed (it derives from the journal). + let cache = crate::projects::cache_file(app); + if cache.exists() { + std::fs::remove_file(&cache).ok(); + repaired.push(format!("project cache {} removed (rebuilt on next 'am projects')", cache.display())); + } + // Corrupt state file: back it up and start fresh. if app.state.path().exists() && app.state.load().is_err() { let backup = app diff --git a/src/help.rs b/src/help.rs index b892e7b..5d4a074 100644 --- a/src/help.rs +++ b/src/help.rs @@ -217,6 +217,23 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, ], }, + HelpSpec { + name: "projects", + category: "Commands", + usage: "projects {flags} [name]", + about: "Show which agent works on which project (derived from the journal).", + search_terms: &["workspace", "repository", "activity"], + flags: &[], + subcommands: &[], + parameters: &[ + HelpParam { name: "name", typ: "string", desc: "Project name (omit to list every project)" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Every project with activity.", code: "projects" }, + HelpExample { desc: "One project in detail.", code: "projects api" }, + ], + }, HelpSpec { name: "log", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index 180434e..a8e59ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod installers; pub mod nav; pub mod output; pub mod probe; +pub mod projects; pub mod ps; pub mod process; pub mod repl; diff --git a/src/projects.rs b/src/projects.rs new file mode 100644 index 0000000..7423e70 --- /dev/null +++ b/src/projects.rs @@ -0,0 +1,185 @@ +//! Project aggregation: which agent works on which project. Derived from +//! the event journal (project field), with a best-effort cache written to +//! projects.json (refreshed by 'am doctor --fix'). + +use crate::app::App; +use crate::config::ProjectProfile; +use crate::events::{self, EventKind}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct ProjectSummary { + pub root: String, + pub agents: Vec, + pub dominant: Option, + pub sessions: u64, + pub launches: u64, + pub total_duration_s: u64, + pub last_activity: Option, +} + +/// Cache file (regenerated on read; deleted by doctor --fix). +pub fn cache_file(app: &App) -> PathBuf { + app.events_dir().join("projects.json") +} + +/// Aggregate the journal into per-project summaries, sorted by last +/// activity (most recent first). Also refreshes the cache file. +pub fn aggregate(app: &App) -> Vec { + let mut map: BTreeMap = BTreeMap::new(); + let mut agent_counts: BTreeMap> = BTreeMap::new(); + let mut sessions: BTreeMap> = BTreeMap::new(); + for ev in events::read_events(&app.events_dir(), 0) { + let Some(project) = ev.project else { + continue; + }; + let s = map + .entry(project.clone()) + .or_insert_with(|| ProjectSummary { + root: project.clone(), + ..Default::default() + }); + if let Some(a) = ev.agent { + *agent_counts + .entry(project.clone()) + .or_default() + .entry(a) + .or_default() += 1; + } + if let Some(sid) = ev.session { + sessions.entry(project.clone()).or_default().insert(sid); + } + match ev.kind { + EventKind::Start | EventKind::Run => s.launches += 1, + _ => {} + } + if let Some(d) = ev.duration_s { + s.total_duration_s += d; + } + if ev.ts > s.last_activity.clone().unwrap_or_default() { + s.last_activity = Some(ev.ts); + } + } + for (p, s) in map.iter_mut() { + s.agents = agent_counts + .get(p) + .map(|c| c.keys().cloned().collect()) + .unwrap_or_default(); + s.agents.sort(); + s.dominant = agent_counts + .get(p) + .and_then(|c| c.iter().max_by_key(|(_, n)| *n).map(|(a, _)| a.clone())); + s.sessions = sessions.get(p).map(|set| set.len() as u64).unwrap_or(0); + } + let mut out: Vec = map.into_values().collect(); + out.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); + // Best-effort cache (readable by scripts and backups). + if let Ok(text) = serde_json::to_string_pretty(&out) { + let _ = std::fs::write(cache_file(app), text); + } + out +} + +/// The project profile whose root matches cwd (the longest root wins). +pub fn profile_for<'a>(app: &'a App, cwd: &Path) -> Option<(&'a str, &'a ProjectProfile)> { + let mut best: Option<(&str, &ProjectProfile)> = None; + for (name, p) in &app.config.projects { + let root = crate::config::expand_path(&p.root); + let matches = cwd.starts_with(&root) || cwd == root; + if matches { + let better = best + .map(|(_, b)| { + let broot = crate::config::expand_path(&b.root); + root.components().count() > broot.components().count() + }) + .unwrap_or(true); + if better { + best = Some((name.as_str(), p)); + } + } + } + best +} + +/// Default agent of the project containing cwd (issue #30 helper). +pub fn default_agent_for_dir(app: &App, cwd: &Path) -> Option { + profile_for(app, cwd) + .and_then(|(_, p)| p.default_agent.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn app_with_events(lines: &[&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 month = chrono::Utc::now().format("%Y%m"); + let path = dir.join(format!("events-{month}.jsonl")); + let mut text = String::new(); + for l in lines { + text.push_str(l); + text.push('\n'); + } + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, text).unwrap(); + app + } + + #[test] + fn aggregates_projects_from_events() { + let app = app_with_events(&[ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#, + r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#, + r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#, + r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#, + ]); + let out = aggregate(&app); + assert_eq!(out.len(), 2); + let api = out.iter().find(|s| s.root == "/home/x/api").unwrap(); + assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]); + assert_eq!(api.dominant.as_deref(), Some("claude-code")); + assert_eq!(api.sessions, 3); + assert_eq!(api.launches, 3); + assert_eq!(api.total_duration_s, 35); + assert!(api.last_activity.is_some()); + assert!(cache_file(&app).exists(), "cache written"); + } + + #[test] + fn profile_matching_prefers_longest_root() { + let app = app_with_events(&[]); + let tmp = std::env::temp_dir(); + let api = tmp.join("p1-api"); + let sub = api.join("src"); + let _ = (api, sub); + // Ce test est complete dans l'integration (config avec profils) ; + // ici on verifie juste l'absence de profil par defaut. + assert!(default_agent_for_dir(&app, &std::env::temp_dir()).is_none()); + } +} diff --git a/src/repl.rs b/src/repl.rs index 97191a0..99152a7 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -62,6 +62,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("status", "show agent state"), ("sessions", "list agent sessions"), ("stats", "usage statistics"), + ("projects", "project activity"), ("log", "read the event journal"), ("logs", "tail an agent log"), ("history", "search command history"), @@ -144,7 +145,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "log", "logs", "history", "init", "alias", "open", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "projects", "log", "logs", "history", "init", "alias", "open", "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", @@ -1255,6 +1256,9 @@ fn handle_line( agent: rest.first().cloned(), period: opt_value("--period"), }, + "projects" => Command::Projects { + name: rest.first().cloned(), + }, "log" => Command::Log { agent: rest.first().cloned(), kind: opt_value("--kind"), @@ -1378,6 +1382,7 @@ fn is_am_command(word: &str) -> bool { | "status" | "sessions" | "stats" + | "projects" | "log" | "logs" | "history" diff --git a/tests/projects_test.rs b/tests/projects_test.rs new file mode 100644 index 0000000..c2d1b8e --- /dev/null +++ b/tests/projects_test.rs @@ -0,0 +1,79 @@ +//! projects: aggregation from the journal, profiles from the config. + +mod common; + +use agent_manager::commands::projects_cmd; +use agent_manager::projects; + +#[test] +fn aggregates_projects_from_fixture_events() { + let app = common::test_app("proj", &["projects"]); + 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(); + std::fs::write( + &path, + [ + r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#, + r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#, + r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#, + r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#, + ] + .join(" +"), + ) + .unwrap(); + let out = projects::aggregate(&app); + assert_eq!(out.len(), 2); + let api = out.iter().find(|s| s.root == "/home/x/api").unwrap(); + assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]); + assert_eq!(api.dominant.as_deref(), Some("claude-code")); + assert_eq!(api.sessions, 3); + assert_eq!(api.launches, 3); + assert_eq!(api.total_duration_s, 35); + assert!(projects::cache_file(&app).exists()); + // La commande tourne (table + detail). + assert_eq!(projects_cmd::run(&app, None).unwrap(), 0); + assert_eq!(projects_cmd::run(&app, Some("api")).unwrap(), 0); + assert!(projects_cmd::run(&app, Some("inconnu")).is_err()); +} + +#[test] +fn profile_config_parses_and_resolves() { + let dir = std::env::temp_dir().join(format!("am-prof-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let root = dir.join("api"); + std::fs::create_dir_all(&root).unwrap(); + let cfg = common::write_config( + &dir, + &format!( + r#" +projects: + api: + root: '{}' + default_agent: claude-code + env: + FOO: bar +"#, + root.display() + ), + ); + use agent_manager::cli::Cli; + use clap::Parser; + let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "projects"]); + let mut app = agent_manager::app::App::from_cli(cli).unwrap(); + common::isolate(&mut app, &dir); + assert_eq!(app.config.projects.len(), 1); + let sub = root.join("src"); + std::fs::create_dir_all(&sub).unwrap(); + let (name, profile) = projects::profile_for(&app, &sub).unwrap(); + assert_eq!(name, "api"); + assert_eq!(profile.default_agent.as_deref(), Some("claude-code")); + assert_eq!( + projects::default_agent_for_dir(&app, &sub).as_deref(), + Some("claude-code") + ); + // Hors du projet : pas de profil. + assert!(projects::default_agent_for_dir(&app, &std::env::temp_dir()).is_none()); + let _ = std::fs::remove_dir_all(&dir); +}