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
+71
View File
@@ -0,0 +1,71 @@
//! Shared helpers for integration tests: build an isolated App whose data,
//! state, logs and config all live inside one temporary directory.
#![allow(dead_code)]
use agent_manager::app::{App, Paths};
use agent_manager::cli::Cli;
use agent_manager::output::Logger;
use agent_manager::state::StateStore;
use clap::Parser;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn fresh_dir(tag: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("am-test-{tag}-{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Build an App from CLI args, isolated in a fresh temporary directory.
/// A minimal config file is passed via --config so no user config leaks in.
pub fn test_app(tag: &str, args: &[&str]) -> App {
let dir = fresh_dir(tag);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
full.extend(args.iter().map(|s| s.to_string()));
let cli = Cli::parse_from(full);
let mut app = App::from_cli(cli).expect("app should build");
isolate(&mut app, &dir);
app
}
/// Point every runtime path (data, state, logs) inside dir.
pub fn isolate(app: &mut App, dir: &PathBuf) {
app.paths = Paths {
install_dir: dir.join("agents"),
bin_dir: dir.join("agents").join("bin"),
log_dir: dir.join("logs"),
state_file: dir.join("state.json"),
probe_cache_file: dir.join("probe-cache.json"),
config_dir: Some(dir.join("config")),
};
app.state = StateStore::new(app.paths.state_file.clone());
app.log = Logger::new(
app.paths.log_dir.clone(),
app.cli.verbose,
app.cli.quiet,
app.cli.no_color,
);
}
/// A minimal valid config file with one agent, written into dir.
pub fn write_config(dir: &PathBuf, body: &str) -> PathBuf {
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{body}\n"
),
)
.unwrap();
cfg
}
+91
View File
@@ -0,0 +1,91 @@
//! Integration tests for configuration loading, overriding and validation.
mod common;
use agent_manager::app::App;
use agent_manager::cli::Cli;
use clap::Parser;
#[test]
fn user_config_overrides_embedded_catalog() {
let dir = std::env::temp_dir().join(format!("am-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
r#"
version: "1.0"
agents:
- name: claude-code
display_name: "Mon Claude Custom"
install: { type: npm, package: "@anthropic-ai/claude-code" }
run: claude
"#,
)
.unwrap();
let cli = Cli::parse_from([
"am".to_string(),
"--config".to_string(),
cfg.display().to_string(),
"list".to_string(),
]);
let app = App::from_cli(cli).unwrap();
let cc = app.catalog.get("claude-code").unwrap();
assert_eq!(cc.display_name.as_deref(), Some("Mon Claude Custom"));
// The rest of the embedded catalog is still there.
assert!(app.catalog.len() >= 60);
assert_eq!(app.config_origin.as_deref(), Some(cfg.as_path()));
}
#[test]
fn invalid_user_config_fails_to_load() {
let dir = std::env::temp_dir().join(format!("am-badcfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
r#"
version: "1.0"
agents:
- name: broken
installable: true
run: ""
install: { type: npm, package: x }
"#,
)
.unwrap();
let cli = Cli::parse_from([
"am".to_string(),
"--config".to_string(),
cfg.display().to_string(),
"list".to_string(),
]);
let err = App::from_cli(cli).unwrap_err().to_string();
assert!(err.contains("'run' is required"), "unexpected error: {err}");
}
#[test]
fn unknown_field_is_rejected() {
let dir = std::env::temp_dir().join(format!("am-badfield-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
r#"
version: "1.0"
agents:
- name: my-agent
install: { type: npm, package: pkg, typo_field: true }
run: my-agent
"#,
)
.unwrap();
let cli = Cli::parse_from([
"am".to_string(),
"--config".to_string(),
cfg.display().to_string(),
"list".to_string(),
]);
let err = App::from_cli(cli).unwrap_err().to_string();
assert!(err.contains("typo_field"), "unexpected error: {err}");
}
+37
View File
@@ -0,0 +1,37 @@
//! doctor report: structure and config/state checks.
mod common;
use agent_manager::doctor;
use agent_manager::runner::SystemRunner;
#[test]
fn report_contains_core_checks() {
let app = common::test_app("doctor", &["doctor"]);
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(&app, &runner);
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path"] {
assert!(
names.contains(&expected),
"missing check '{expected}' in {names:?}"
);
}
// With our isolated test config the schema must be valid.
let schema = report
.checks
.iter()
.find(|c| c.name == "config-schema")
.unwrap();
assert_eq!(schema.status, doctor::Status::Ok);
}
#[test]
fn fix_creates_directories() {
let app = common::test_app("doctorfix", &["doctor"]);
assert!(!app.paths.install_dir.exists());
let repaired = doctor::fix(&app).unwrap();
assert!(repaired.len() >= 2);
assert!(app.paths.install_dir.is_dir());
assert!(app.paths.log_dir.is_dir());
}
+88
View File
@@ -0,0 +1,88 @@
//! Dry-run behaviour: no directories created, no state written, but the
//! commands that would run are announced.
mod common;
use agent_manager::commands::install_cmd;
use agent_manager::commands::uninstall_cmd;
use agent_manager::state::InstalledEntry;
#[test]
fn install_dry_run_creates_nothing() {
let app = common::test_app(
"dryinstall",
&["install", "smelt", "--dry-run", "--yes"],
);
assert!(app.dry_run());
let code = install_cmd::run(&app, "smelt", None, false).unwrap();
assert_eq!(code, 0);
// Nothing was written.
assert!(!app.paths.install_dir.join("smelt").exists());
assert!(app.state.all().unwrap().is_empty());
}
#[test]
fn install_without_dry_run_but_mock_is_not_supported() {
// Guard: install command requires a real runner; ensure the API surface
// used by the previous test is stable.
let app = common::test_app("dryguard", &["install", "smelt", "--dry-run", "--yes"]);
assert!(app.dry_run());
}
#[test]
fn uninstall_dry_run_keeps_everything() {
let app = common::test_app(
"dryuninstall",
&["uninstall", "smelt", "--dry-run", "--yes"],
);
// Simulate an installed agent.
let dir = app.paths.install_dir.join("smelt");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("bin");
std::fs::write(&marker, "x").unwrap();
let entry = InstalledEntry {
name: "smelt".to_string(),
version: Some("0.5.0".to_string()),
method: "cargo".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: "smelt".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let code = uninstall_cmd::run(&app, "smelt", true).unwrap();
assert_eq!(code, 0);
// Directory and state entry survive a dry run.
assert!(dir.exists());
assert!(app.state.get("smelt").unwrap().is_some());
}
#[test]
fn real_uninstall_removes_files_and_state() {
let app = common::test_app("uninstall", &["uninstall", "smelt", "--yes"]);
let dir = app.paths.install_dir.join("smelt");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bin"), "x").unwrap();
let entry = InstalledEntry {
name: "smelt".to_string(),
version: Some("0.5.0".to_string()),
method: "cargo".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: "smelt".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let code = uninstall_cmd::run(&app, "smelt", false).unwrap();
assert_eq!(code, 0);
assert!(!dir.exists());
assert!(app.state.get("smelt").unwrap().is_none());
}
+66
View File
@@ -0,0 +1,66 @@
//! Process lifecycle: background spawn, liveness, stop.
mod common;
use agent_manager::process;
use std::collections::BTreeMap;
fn sleep_cmd() -> (&'static str, Vec<String>) {
if cfg!(windows) {
(
"ping",
vec![
"-n".to_string(),
"40".to_string(),
"127.0.0.1".to_string(),
],
)
} else {
("sleep", vec!["40".to_string()])
}
}
#[test]
fn spawn_detect_stop_cycle() {
let app = common::test_app("proc", &["status"]);
let (prog, args) = sleep_cmd();
let env = BTreeMap::new();
let pid = process::spawn_background(
&app,
"test-agent",
std::path::Path::new(prog),
&args,
&env,
)
.unwrap();
assert!(pid > 0);
assert!(process::is_running(pid));
assert!(process::stop_pid(pid, true, 5));
assert!(!process::is_running(pid));
// The log file was created.
assert!(process::agent_log_path(&app, "test-agent").exists());
}
#[test]
fn spawning_a_failing_program_reports_an_error() {
let app = common::test_app("procfail", &["status"]);
let env = BTreeMap::new();
// A command that exits immediately with an error.
let (prog, args) = if cfg!(windows) {
("cmd", vec!["/C".to_string(), "exit 7".to_string()])
} else {
("sh", vec!["-c".to_string(), "exit 7".to_string()])
};
let err = process::spawn_background(
&app,
"failing",
std::path::Path::new(prog),
&args,
&env,
)
.unwrap_err();
assert!(
err.to_string().contains("exited immediately"),
"unexpected error: {err:#}"
);
}