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