diff --git a/src/cli.rs b/src/cli.rs index 54c4d81..c97890e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -195,6 +195,12 @@ pub enum Command { /// Agent name or alias agent: String, }, + /// Generate a local agent-manager.yaml for the current directory + Init { + /// Overwrite an existing agent-manager.yaml + #[arg(long)] + force: bool, + }, /// Display help: the overview, a command, the command list, or a search Help { /// Command name (also accepts an agent name or alias); "commands" lists every command diff --git a/src/commands/init_cmd.rs b/src/commands/init_cmd.rs new file mode 100644 index 0000000..bee87e0 --- /dev/null +++ b/src/commands/init_cmd.rs @@ -0,0 +1,70 @@ +//! init: generate a local agent-manager.yaml for the current directory, +//! based on the detected technology stack. + +use super::*; +use crate::context; +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; + +/// Generate a local config file in dir; refuses to overwrite unless force. +/// Returns the written path. +pub fn generate(dir: &Path, force: bool) -> Result { + let target = dir.join(crate::config::LOCAL_CONFIG_NAME); + if target.exists() && !force { + return Err(anyhow!( + "{} already exists — use --force to overwrite it", + target.display() + )); + } + let ctx = context::detect(dir); + let agents = context::recommended_agents(&ctx.stack); + let members = agents + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(", "); + let stack = if ctx.stack.is_empty() { + "aucune détectée".to_string() + } else { + ctx.stack.join(", ") + }; + let content = format!( + "# Généré par 'am init' le {date} +# Pile détectée : {stack} +version: \"1.0\" +groups: + dev: [{members}] +agents: [] +", + date = crate::installers::now_rfc3339(), + ); + std::fs::write(&target, content) + .with_context(|| format!("cannot write {}", target.display()))?; + Ok(target) +} + +pub fn run(app: &App, force: bool) -> Result { + if app.dry_run() { + app.log.dry(format!( + "would generate {} in the current directory", + crate::config::LOCAL_CONFIG_NAME + )); + return Ok(0); + } + let cwd = std::env::current_dir().context("cannot determine the current directory")?; + let ctx = context::detect(&cwd); + let path = generate(&cwd, force)?; + app.log.success(&format!( + "wrote {} (stack: {})", + path.display(), + if ctx.stack.is_empty() { + "aucune détectée".to_string() + } else { + ctx.stack.join(", ") + } + )); + app.log.info(&format!( + "group 'dev' prêt : am start group:dev — ajustez ensuite la config générée" + )); + Ok(0) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dcbbce7..75d5684 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod export_import; pub mod help_cmd; pub mod history_cmd; pub mod info_cmd; +pub mod init_cmd; pub mod install_cmd; pub mod list_cmd; pub mod log_cmd; @@ -96,6 +97,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { *limit, ), Command::Info { agent } => info_cmd::run(app, agent), + Command::Init { force } => init_cmd::run(app, *force), Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()), Command::Version => version_cmd::run(app), Command::Config(sub) => config_cmd::run(app, sub), diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index 0c0d6b7..ad6583b 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -39,6 +39,7 @@ fn start_one( let exec = resolve_exec(app, agent, extra_args, extra_env)?; let bin = PathBuf::from(&exec.program); let is_managed = app.state.get(&agent.name).ok().flatten().is_some(); + let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default()); if background { let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?; if app.dry_run() { @@ -59,6 +60,7 @@ fn start_one( .with_pid(pid) .with_session(crate::repl::session_id()) .with_cwd(cwd_string()) + .with_project(ctx.root.clone()) .with_args(exec.args.clone()) .with_env_keys(events::env_keys(&exec.env)), ); @@ -71,6 +73,7 @@ fn start_one( &agent.name, pid, cwd_string(), + ctx.root.clone(), exec.args.clone(), events::env_keys(&exec.env), Some(log.display().to_string()), @@ -107,6 +110,7 @@ fn start_one( &Event::now(EventKind::Run) .with_agent(agent.name.clone()) .with_cwd(cwd_string()) + .with_project(ctx.root.clone()) .with_args(exec.args.clone()) .with_env_keys(events::env_keys(&exec.env)) .with_exit_code(code) diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..d252916 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,153 @@ +//! Project context detection: git root + branch and the technology +//! stack of a directory (marker files). Cached per process so repeated +//! calls (event emission, sessions) never re-run subprocesses. + +use serde::Serialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct ProjectContext { + /// Root of the enclosing git repository (or None). + pub root: Option, + /// Current branch (or None). + pub branch: Option, + /// Detected technology stack, e.g. ["rust", "node"]. + pub stack: Vec, +} + +/// Marker file -> stack label. Checked walking up from the directory to the +/// filesystem root; the first directory containing a marker wins. +const MARKERS: &[(&str, &str)] = &[ + ("Cargo.toml", "rust"), + ("package.json", "node"), + ("pyproject.toml", "python"), + ("requirements.txt", "python"), + ("setup.py", "python"), + ("go.mod", "go"), + ("bun.lockb", "bun"), + ("bun.lock", "bun"), + ("Gemfile", "ruby"), +]; + +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static Mutex> { + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Detect the project context of a directory (cached per process). +pub fn detect(cwd: &Path) -> ProjectContext { + if let Some(hit) = cache().lock().unwrap().get(cwd).cloned() { + return hit; + } + let ctx = detect_uncached(cwd); + cache().lock().unwrap().insert(cwd.to_path_buf(), ctx.clone()); + ctx +} + +fn detect_uncached(cwd: &Path) -> ProjectContext { + let (root, branch) = git_info(cwd); + let stack = detect_stack(cwd); + ProjectContext { + root, + branch, + stack, + } +} + +/// Git root and branch via git subprocesses (best effort, silent). +fn git_info(cwd: &Path) -> (Option, Option) { + let root = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + let branch = std::process::Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(cwd) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|b| !b.is_empty()); + (root, branch) +} + +/// Walk up from cwd collecting stack labels from marker files. +pub fn detect_stack(cwd: &Path) -> Vec { + let mut dir = Some(cwd); + let mut out: Vec = Vec::new(); + while let Some(d) = dir { + for (marker, label) in MARKERS { + if d.join(marker).is_file() && !out.iter().any(|s| s == label) { + out.push(label.to_string()); + } + } + dir = d.parent(); + } + out.sort(); + out +} + +/// Agents recommended for a detected stack (catalog slugs). +pub fn recommended_agents(stack: &[String]) -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + if stack.contains(&"rust".to_string()) { + out.push("smelt"); + } + if stack.contains(&"python".to_string()) { + out.push("aider"); + } + if stack.contains(&"node".to_string()) { + out.push("claude-code"); + } + if out.is_empty() { + out.push("claude-code"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_stack_from_markers() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + let stack = detect_stack(dir.path()); + assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]); + assert_eq!(recommended_agents(&stack), vec!["smelt", "claude-code"]); + } + + #[test] + fn walks_up_to_parent_directories() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("go.mod"), "").unwrap(); + let sub = dir.path().join("src/deep"); + std::fs::create_dir_all(&sub).unwrap(); + let stack = detect_stack(&sub); + assert_eq!(stack, vec!["go".to_string()]); + } + + #[test] + fn empty_dir_recommends_default() { + let dir = tempfile::tempdir().unwrap(); + let stack = detect_stack(dir.path()); + assert!(stack.is_empty()); + assert_eq!(recommended_agents(&stack), vec!["claude-code"]); + } + + #[test] + fn cache_returns_same_context() { + let dir = tempfile::tempdir().unwrap(); + let a = detect(dir.path()); + let b = detect(dir.path()); + assert_eq!(a, b); + } +} diff --git a/src/events.rs b/src/events.rs index ef868b7..9d27ffc 100644 --- a/src/events.rs +++ b/src/events.rs @@ -113,8 +113,8 @@ impl Event { self } - pub fn with_project(mut self, project: impl Into) -> Self { - self.project = Some(project.into()); + pub fn with_project(mut self, project: Option) -> Self { + self.project = project; self } diff --git a/src/help.rs b/src/help.rs index 714dc33..4610a16 100644 --- a/src/help.rs +++ b/src/help.rs @@ -296,6 +296,23 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Use an alias.", code: "info cc" }, ], }, + HelpSpec { + name: "init", + category: "Commands", + usage: "init {flags}", + about: "Generate a local agent-manager.yaml for the current directory, based on the detected stack.", + search_terms: &["project", "setup", "scaffold"], + flags: &[ + HelpFlag { short: "", long: "--force", value: "", desc: "Overwrite an existing agent-manager.yaml" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Generate a project config.", code: "init" }, + HelpExample { desc: "Regenerate over an existing one.", code: "init --force" }, + ], + }, HelpSpec { name: "install", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index 85b1a62..180434e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod catalog; pub mod cli; pub mod commands; pub mod config; +pub mod context; pub mod deps; pub mod doctor; pub mod download; diff --git a/src/repl.rs b/src/repl.rs index 679318f..16c8ae7 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -64,6 +64,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("stats", "usage statistics"), ("log", "read the event journal"), ("history", "search command history"), + ("init", "generate a project config"), ("search", "search the catalog"), ("info", "agent details"), ("install", "install an agent"), @@ -140,7 +141,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "log", "history", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "log", "history", "init", "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", @@ -1199,6 +1200,31 @@ fn handle_line( "status" => Command::Status { agent: rest.first().cloned(), }, + "sessions" => Command::Sessions { + agent: rest.first().cloned(), + project: opt_value("--project"), + status: opt_value("--status"), + show: opt_value("--show"), + }, + "stats" => Command::Stats { + agent: rest.first().cloned(), + period: opt_value("--period"), + }, + "log" => Command::Log { + agent: rest.first().cloned(), + kind: opt_value("--kind"), + since: opt_value("--since"), + limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50), + }, + "history" => Command::History { + kind: opt_value("--kind"), + cwd: opt_value("--cwd"), + search: opt_value("--search"), + failed: flag("--failed"), + session: opt_value("--session"), + limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100), + }, + "init" => Command::Init { force: flag("--force") }, "search" => Command::Search { keyword: rest.join(" "), category: None, @@ -1308,6 +1334,7 @@ fn is_am_command(word: &str) -> bool { | "stats" | "log" | "history" + | "init" | "search" | "info" | "install" diff --git a/src/sessions.rs b/src/sessions.rs index 7343bbd..fc60928 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -107,6 +107,7 @@ pub fn start_agent( agent: &str, pid: u32, cwd: Option, + project: Option, args: Vec, env_keys: Vec, log: Option, @@ -121,7 +122,7 @@ pub fn start_agent( agent: Some(agent.to_string()), pid: Some(pid), cwd, - project: None, + project, started_at: crate::installers::now_rfc3339(), ended_at: None, exit_code: None, @@ -283,7 +284,7 @@ mod tests { #[test] fn agent_session_lifecycle() { let app = test_app("sess"); - let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), vec![], vec![], None).unwrap(); + let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], None).unwrap(); let sf = load(&app).unwrap(); let r = sf.sessions.get(&id).unwrap(); assert_eq!(r.status, "running"); @@ -302,7 +303,7 @@ mod tests { #[test] fn reconcile_marks_dead_pid_interrupted() { let app = test_app("recon"); - let id = start_agent(&app, "aider", u32::MAX - 1, None, vec![], vec![], None).unwrap(); + let id = start_agent(&app, "aider", u32::MAX - 1, None, None, vec![], vec![], None).unwrap(); let changed = reconcile(&app).unwrap(); assert_eq!(changed, 1); let sf = load(&app).unwrap(); @@ -321,8 +322,8 @@ mod tests { #[test] fn finish_matches_by_pid_first() { let app = test_app("multi"); - start_agent(&app, "jcode", 100, None, vec![], vec![], None).unwrap(); - start_agent(&app, "jcode", 200, None, vec![], vec![], None).unwrap(); + start_agent(&app, "jcode", 100, None, None, vec![], vec![], None).unwrap(); + start_agent(&app, "jcode", 200, None, None, vec![], vec![], None).unwrap(); finish_agent(&app, "jcode", Some(200), 1, false).unwrap(); let sf = load(&app).unwrap(); let stopped: Vec<_> = sf diff --git a/tests/init_test.rs b/tests/init_test.rs new file mode 100644 index 0000000..ca7ba68 --- /dev/null +++ b/tests/init_test.rs @@ -0,0 +1,46 @@ +//! init + context detection. + +mod common; + +use agent_manager::commands::init_cmd; +use agent_manager::context; + +#[test] +fn context_detects_stack_from_markers() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + let stack = context::detect_stack(dir.path()); + assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]); + assert_eq!(context::recommended_agents(&stack), vec!["smelt", "claude-code"]); +} + +#[test] +fn generate_writes_valid_config_and_refuses_overwrite() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); + let path = init_cmd::generate(dir.path(), false).unwrap(); + assert!(path.ends_with("agent-manager.yaml")); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains(r#"version: "1.0""#), "config valide: {text}"); + assert!(text.contains("smelt"), "agent recommande pour rust: {text}"); + // Un second appel sans --force échoue. + assert!(init_cmd::generate(dir.path(), false).is_err()); + // Avec --force, il écrase. + assert!(init_cmd::generate(dir.path(), true).is_ok()); +} + +#[test] +fn generate_uses_default_for_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let path = init_cmd::generate(dir.path(), false).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("claude-code"), "default agent: {text}"); +} + +#[test] +fn command_runs_dry() { + let app = common::test_app("initdry", &["init", "--dry-run"]); + let code = init_cmd::run(&app, false).unwrap(); + assert_eq!(code, 0); +} diff --git a/tests/sessions_test.rs b/tests/sessions_test.rs index 00b7877..61412d8 100644 --- a/tests/sessions_test.rs +++ b/tests/sessions_test.rs @@ -34,6 +34,7 @@ fn agent_session_roundtrip_and_counter() { "claude-code", 4242, Some("/tmp".to_string()), + None, vec![], vec![], None, @@ -56,7 +57,7 @@ fn agent_session_roundtrip_and_counter() { #[test] fn command_lists_sessions() { let app = common::test_app("sesscmd", &["sessions"]); - sessions::start_agent(&app, "aider", 4242, None, vec![], vec![], None).unwrap(); + sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap(); let code = sessions_cmd::run(&app, None, None, None, None).unwrap(); assert_eq!(code, 0); // --json path returns the row set without error. @@ -66,7 +67,7 @@ fn command_lists_sessions() { #[test] fn show_one_accepts_known_id() { let app = common::test_app("sessshow", &["sessions"]); - let id = sessions::start_agent(&app, "jcode", 99, None, vec![], vec![], None).unwrap(); + let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap(); let code = sessions_cmd::run(&app, None, None, None, Some(&id)).unwrap(); assert_eq!(code, 0); }