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
+229
View File
@@ -0,0 +1,229 @@
//! doctor: environment checks — tools, config validity, paths,
//! permissions — with an optional --fix mode.
use crate::app::App;
use crate::runner::Runner;
use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
pub struct DoctorReport {
pub checks: Vec<Check>,
}
impl DoctorReport {
pub fn ok(&self) -> bool {
!self.checks.iter().any(|c| c.status == Status::Error)
}
pub fn problems(&self) -> usize {
self.checks.iter().filter(|c| c.status != Status::Ok).count()
}
}
/// Args for probing a tool's version.
fn probe_args(tool: &str) -> Vec<String> {
match tool {
"go" => vec!["version".to_string()],
_ => vec!["--version".to_string()],
}
}
pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
let mut checks = Vec::new();
// --- tools ----------------------------------------------------------
// (name, required)
let tools: &[(&str, bool)] = &[
("git", true),
("sh", cfg!(unix)),
("curl", true),
("node", false),
("npm", false),
("python3", false),
("python", false),
("cargo", false),
("go", false),
("bun", false),
("uv", false),
("docker", false),
];
for (tool, required) in tools {
match runner.which(tool) {
Some(path) => {
let mut detail = path.display().to_string();
let empty = BTreeMap::new();
if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) {
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
detail = format!("{} (v{v})", path.display());
}
}
}
checks.push(Check {
name: tool.to_string(),
status: Status::Ok,
detail,
});
}
None => {
let (status, detail) = if *required {
(
Status::Error,
"required but not found on PATH".to_string(),
)
} else {
(Status::Ok, "not installed".to_string())
};
checks.push(Check {
name: tool.to_string(),
status,
detail,
});
}
}
}
// --- configuration ---------------------------------------------------
match &app.config_origin {
Some(path) => checks.push(Check {
name: "config".to_string(),
status: Status::Ok,
detail: path.display().to_string(),
}),
None => checks.push(Check {
name: "config".to_string(),
status: Status::Warn,
detail: "no user config file found; using the embedded default catalog".to_string(),
}),
}
let problems = crate::config::validate(&app.config);
if problems.is_empty() {
checks.push(Check {
name: "config-schema".to_string(),
status: Status::Ok,
detail: format!("{} agents defined", app.catalog.len()),
});
} else {
checks.push(Check {
name: "config-schema".to_string(),
status: Status::Error,
detail: problems.join("; "),
});
}
// --- state file ------------------------------------------------------
match app.state.load() {
Ok(sf) => checks.push(Check {
name: "state".to_string(),
status: Status::Ok,
detail: format!("{} installed agent(s)", sf.installed.len()),
}),
Err(e) => checks.push(Check {
name: "state".to_string(),
status: Status::Error,
detail: format!("{e:#}"),
}),
}
// --- directories -----------------------------------------------------
for (name, dir) in [
("install-dir", app.paths.install_dir.clone()),
("bin-dir", app.paths.bin_dir.clone()),
("log-dir", app.paths.log_dir.clone()),
] {
let status = if dir.is_dir() {
Status::Ok
} else if app.paths.install_dir.parent().map(|p| p.is_dir()).unwrap_or(false) {
Status::Warn
} else {
Status::Warn
};
let detail = if dir.is_dir() {
dir.display().to_string()
} else {
format!("{} (missing; 'am doctor --fix' creates it)", dir.display())
};
checks.push(Check {
name: name.to_string(),
status,
detail,
});
}
// --- bin dir on PATH --------------------------------------------------
let on_path = is_on_path(&app.paths.bin_dir);
checks.push(Check {
name: "bin-on-path".to_string(),
status: if on_path { Status::Ok } else { Status::Warn },
detail: if on_path {
format!("{} is on PATH", app.paths.bin_dir.display())
} else {
format!(
"{} is not on PATH — add it so agents are found from your shell",
app.paths.bin_dir.display()
)
},
});
DoctorReport { checks }
}
fn is_on_path(dir: &std::path::Path) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|p| {
if cfg!(windows) {
p.to_string_lossy().to_lowercase() == dir.to_string_lossy().to_lowercase()
} else {
p == dir
}
})
}
/// Repair what can be repaired: create directories, fix a corrupt state file.
pub fn fix(app: &App) -> Result<Vec<String>> {
let mut repaired = Vec::new();
app.paths.ensure().context("creating directories")?;
repaired.push(format!("created {}", app.paths.install_dir.display()));
repaired.push(format!("created {}", app.paths.log_dir.display()));
// Corrupt state file: back it up and start fresh.
if app.state.path().exists() && app.state.load().is_err() {
let backup = app
.state
.path()
.with_extension("json.corrupt.bak");
std::fs::rename(app.state.path(), &backup).with_context(|| {
format!(
"cannot back up corrupt state file {} to {}",
app.state.path().display(),
backup.display()
)
})?;
app.state.save(&crate::state::StateFile::default())?;
repaired.push(format!(
"corrupt state file moved to {} and recreated empty",
backup.display()
));
}
Ok(repaired)
}