agent-manager v0.2.0 : gestionnaire d'agents IA locaux

- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique)
- 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git)
- catalogue de 72 agents, dependances, processus, securite (dry-run, checksums)
- sondes de version paralleles + cache (am list ~25 ms a chaud)
- installateurs one-liner : install.sh, install.ps1
This commit is contained in:
2026-08-15 11:26:34 -04:00
commit 8ee3ddf3db
79 changed files with 15724 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
//! Dependency checking: probe required tools (node, python, go, ...),
//! compare minimum versions, and produce actionable error messages.
use crate::app::App;
use crate::config::{AgentDef, Dependency};
use crate::runner::Runner;
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::cmp::Ordering;
#[derive(Debug, Clone)]
pub struct DepCheck {
pub name: String,
pub found: bool,
pub version: Option<String>,
pub min: Option<String>,
pub ok: bool,
pub detail: String,
}
/// Default probe command for a dependency name: (program, args).
fn default_probe(name: &str) -> (String, Vec<String>) {
match name {
"go" => ("go".to_string(), vec!["version".to_string()]),
_ => (name.to_string(), vec!["--version".to_string()]),
}
}
/// Probe one dependency. Python is special: python3 is preferred.
pub fn check_dep(runner: &dyn Runner, dep: &Dependency) -> DepCheck {
let probes: Vec<(String, Vec<String>)> = if dep.name == "python" {
vec![
("python3".to_string(), vec!["--version".to_string()]),
("python".to_string(), vec!["--version".to_string()]),
]
} else if let Some(check) = &dep.check {
match shell_words::split(check) {
Ok(tokens) if !tokens.is_empty() => {
let prog = tokens[0].clone();
vec![(prog, tokens[1..].to_vec())]
}
_ => vec![default_probe(&dep.name)],
}
} else {
vec![default_probe(&dep.name)]
};
let empty = BTreeMap::new();
for (prog, args) in probes {
if runner.which(&prog).is_none() {
continue;
}
match runner.run(&prog, &args, &empty, None, true) {
Ok(out) if out.code == 0 => {
let combined = format!("{}
{}", out.stdout, out.stderr);
let version = crate::version::find_version(&combined);
let ok = match (&dep.min_version, &version) {
(Some(min), Some(v)) => crate::version::compare(v, min)
.map(|o| o != Ordering::Less)
.unwrap_or(true),
_ => true,
};
let detail = if ok {
version.clone().unwrap_or_else(|| "found".to_string())
} else {
format!(
"version {} is too old (need >= {})",
version.as_deref().unwrap_or("unknown"),
dep.min_version.as_deref().unwrap_or("?")
)
};
return DepCheck {
name: dep.name.clone(),
found: true,
version,
min: dep.min_version.clone(),
ok,
detail,
};
}
Ok(out) => {
return DepCheck {
name: dep.name.clone(),
found: true,
version: None,
min: dep.min_version.clone(),
ok: false,
detail: format!("probe failed (exit code {})", out.code),
};
}
Err(_) => {}
}
}
DepCheck {
name: dep.name.clone(),
found: false,
version: None,
min: dep.min_version.clone(),
ok: false,
detail: "not found on PATH".to_string(),
}
}
/// Check every dependency of an agent.
pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
agent
.dependencies
.iter()
.map(|d| check_dep(runner, d))
.collect()
}
/// Fail with an actionable message when dependencies are missing.
/// With auto_install_deps enabled, dependencies that define an install_cmd
/// are offered for automatic installation first.
pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> {
let mut checks = check_agent(agent, runner);
if checks.iter().all(|c| c.ok) {
return Ok(());
}
if app.config.settings.auto_install_deps() && !app.dry_run() {
for dep in &agent.dependencies {
if dep.install_cmd.is_empty() {
continue;
}
if check_dep(runner, dep).ok {
continue;
}
let cmd_line = dep.install_cmd.join(" ");
app.log.warn(&format!("dependency '{}' is missing", dep.name));
let proceed = app
.confirm(&format!("Run '{cmd_line}' to install it?"))
.unwrap_or(false);
if proceed {
let prog = dep.install_cmd[0].clone();
let args: Vec<String> = dep.install_cmd[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
if out.code != 0 {
app.log
.warn(&format!("automatic installation of '{}' failed", dep.name));
}
}
}
checks = check_agent(agent, runner);
}
if checks.iter().all(|c| c.ok) {
return Ok(());
}
let mut lines = Vec::new();
for c in checks.iter().filter(|c| !c.ok) {
let mut line = format!(" - {}: {}", c.name, c.detail);
if let Some(min) = &c.min {
line.push_str(&format!(" (minimum: {min})"));
}
if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
if let Some(hint) = &dep.install_hint {
line.push_str(&format!(" — install via {hint}"));
}
}
lines.push(line);
}
bail!(
"agent '{}' has unmet dependencies:
{}",
agent.name,
lines.join("
")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Dependency;
use crate::runner::MockRunner;
#[test]
fn missing_tool_reported() {
let runner = MockRunner::new();
let dep = Dependency {
name: "node".to_string(),
check: None,
min_version: Some("18.0.0".to_string()),
install_hint: Some("https://nodejs.org".to_string()),
install_cmd: vec![],
};
let c = check_dep(&runner, &dep);
assert!(!c.ok);
assert!(!c.found);
assert_eq!(c.detail, "not found on PATH");
}
#[test]
fn python3_preferred() {
let runner = MockRunner::new().with_found("python3").with_found("python");
let dep = Dependency {
name: "python".to_string(),
check: None,
min_version: Some("3.9".to_string()),
install_hint: None,
install_cmd: vec![],
};
let c = check_dep(&runner, &dep);
// MockRunner returns empty output => version unknown => ok when no min.
assert!(c.found);
}
}