Merge pull request 'init + contexte : am init et détection de projet (issue #8)' (#23) from feat/init-context into main
This commit was merged in pull request #23.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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::<Vec<_>>()
|
||||
.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<i32> {
|
||||
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)
|
||||
}
|
||||
@@ -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<i32> {
|
||||
*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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+153
@@ -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<String>,
|
||||
/// Current branch (or None).
|
||||
pub branch: Option<String>,
|
||||
/// Detected technology stack, e.g. ["rust", "node"].
|
||||
pub stack: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<Mutex<HashMap<PathBuf, ProjectContext>>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<PathBuf, ProjectContext>> {
|
||||
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<String>, Option<String>) {
|
||||
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<String> {
|
||||
let mut dir = Some(cwd);
|
||||
let mut out: Vec<String> = 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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -113,8 +113,8 @@ impl Event {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_project(mut self, project: impl Into<String>) -> Self {
|
||||
self.project = Some(project.into());
|
||||
pub fn with_project(mut self, project: Option<String>) -> Self {
|
||||
self.project = project;
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+28
-1
@@ -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"
|
||||
|
||||
+6
-5
@@ -107,6 +107,7 @@ pub fn start_agent(
|
||||
agent: &str,
|
||||
pid: u32,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
args: Vec<String>,
|
||||
env_keys: Vec<String>,
|
||||
log: Option<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user