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:
@@ -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
|
||||||
|
|||||||
@@ -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()))?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user