Merge pull request 'projects : am projects + profils de projet (issue #29)' (#45) from feat/projects into main

This commit was merged in pull request #45.
This commit is contained in:
2026-08-17 08:10:57 -04:00
11 changed files with 418 additions and 1 deletions
+5
View File
@@ -145,6 +145,11 @@ pub enum Command {
#[arg(long, value_name = "PERIOD")] #[arg(long, value_name = "PERIOD")]
period: Option<String>, period: Option<String>,
}, },
/// Show which agent works on which project
Projects {
/// Project name (omit to list every project)
name: Option<String>,
},
/// Read the event journal: everything am did /// Read the event journal: everything am did
Log { Log {
/// Only events of this agent /// Only events of this agent
+1
View File
@@ -77,6 +77,7 @@ pub fn import_cmd(app: &App, file: &Path) -> Result<i32> {
groups: Default::default(), groups: Default::default(),
include: vec![], include: vec![],
agents: export.config.agents.clone(), agents: export.config.agents.clone(),
projects: Default::default(),
}; };
std::fs::write(&dest, serde_yaml::to_string(&cfg)?) std::fs::write(&dest, serde_yaml::to_string(&cfg)?)
.with_context(|| format!("cannot write {}", dest.display()))?; .with_context(|| format!("cannot write {}", dest.display()))?;
+2
View File
@@ -14,6 +14,7 @@ pub mod list_cmd;
pub mod log_cmd; pub mod log_cmd;
pub mod logs_cmd; pub mod logs_cmd;
pub mod open_cmd; pub mod open_cmd;
pub mod projects_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod self_uninstall; pub mod self_uninstall;
@@ -75,6 +76,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
since, since,
limit, limit,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *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 { Command::Sessions {
agent, agent,
project, project,
+92
View File
@@ -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<i32> {
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<i32> {
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)
}
}
+23
View File
@@ -35,6 +35,9 @@ pub struct Config {
pub include: Vec<String>, pub include: Vec<String>,
#[serde(default)] #[serde(default)]
pub agents: Vec<AgentDef>, pub agents: Vec<AgentDef>,
/// Per-project profiles: root, default agent, env and hooks.
#[serde(default)]
pub projects: BTreeMap<String, ProjectProfile>,
} }
fn default_version() -> String { fn default_version() -> String {
@@ -72,6 +75,23 @@ pub struct Settings {
pub theme: Option<String>, pub theme: Option<String>,
} }
/// 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<String>,
/// Environment variables injected when launching the default agent.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Hooks: on_start / on_stop / on_install / on_update command lines.
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
}
impl Settings { impl Settings {
pub fn auto_install_deps(&self) -> bool { pub fn auto_install_deps(&self) -> bool {
self.auto_install_deps.unwrap_or(true) self.auto_install_deps.unwrap_or(true)
@@ -490,6 +510,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
base.agents.push(agent); 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. /// Validate a merged config. Returns a list of human-readable problems.
+7
View File
@@ -248,6 +248,13 @@ pub fn fix(app: &App) -> Result<Vec<String>> {
repaired.push(format!("created {}", app.paths.install_dir.display())); repaired.push(format!("created {}", app.paths.install_dir.display()));
repaired.push(format!("created {}", app.paths.log_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. // Corrupt state file: back it up and start fresh.
if app.state.path().exists() && app.state.load().is_err() { if app.state.path().exists() && app.state.load().is_err() {
let backup = app let backup = app
+17
View File
@@ -217,6 +217,23 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, 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 { HelpSpec {
name: "log", name: "log",
category: "Commands", category: "Commands",
+1
View File
@@ -27,6 +27,7 @@ pub mod installers;
pub mod nav; pub mod nav;
pub mod output; pub mod output;
pub mod probe; pub mod probe;
pub mod projects;
pub mod ps; pub mod ps;
pub mod process; pub mod process;
pub mod repl; pub mod repl;
+185
View File
@@ -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<String>,
pub dominant: Option<String>,
pub sessions: u64,
pub launches: u64,
pub total_duration_s: u64,
pub last_activity: Option<String>,
}
/// 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<ProjectSummary> {
let mut map: BTreeMap<String, ProjectSummary> = BTreeMap::new();
let mut agent_counts: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
let mut sessions: BTreeMap<String, BTreeSet<String>> = 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<ProjectSummary> = 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<String> {
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());
}
}
+6 -1
View File
@@ -62,6 +62,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("status", "show agent state"), ("status", "show agent state"),
("sessions", "list agent sessions"), ("sessions", "list agent sessions"),
("stats", "usage statistics"), ("stats", "usage statistics"),
("projects", "project activity"),
("log", "read the event journal"), ("log", "read the event journal"),
("logs", "tail an agent log"), ("logs", "tail an agent log"),
("history", "search command history"), ("history", "search command history"),
@@ -144,7 +145,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", "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", "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",
@@ -1255,6 +1256,9 @@ fn handle_line(
agent: rest.first().cloned(), agent: rest.first().cloned(),
period: opt_value("--period"), period: opt_value("--period"),
}, },
"projects" => Command::Projects {
name: rest.first().cloned(),
},
"log" => Command::Log { "log" => Command::Log {
agent: rest.first().cloned(), agent: rest.first().cloned(),
kind: opt_value("--kind"), kind: opt_value("--kind"),
@@ -1378,6 +1382,7 @@ fn is_am_command(word: &str) -> bool {
| "status" | "status"
| "sessions" | "sessions"
| "stats" | "stats"
| "projects"
| "log" | "log"
| "logs" | "logs"
| "history" | "history"
+79
View File
@@ -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);
}