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,296 @@
|
||||
//! Installer implementations: one module per install method.
|
||||
//!
|
||||
//! All installers share the same helpers (command execution through a
|
||||
//! Runner, bin-dir snapshots, version detection) so they behave identically
|
||||
//! under --dry-run and in tests.
|
||||
|
||||
pub mod binary;
|
||||
pub mod cargo;
|
||||
pub mod git;
|
||||
pub mod golang;
|
||||
pub mod npm;
|
||||
pub mod pipuv;
|
||||
pub mod script;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{AgentDef, InstallType, MethodSpec};
|
||||
use crate::runner::{RunOutput, Runner};
|
||||
use crate::state::InstalledEntry;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Platform executable suffix ("" on Unix, ".exe" on Windows).
|
||||
pub const EXE_SUFFIX: &str = if cfg!(windows) { ".exe" } else { "" };
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InstallOutcome {
|
||||
/// Executables (or shims) that belong to this installation.
|
||||
pub bins: Vec<PathBuf>,
|
||||
/// Detected version (when possible).
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Execute the right installer for the method type.
|
||||
pub fn run_install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
match method.kind {
|
||||
InstallType::Npm => npm::install(app, agent, method, runner),
|
||||
InstallType::Bun => npm::bun_install(app, agent, method, runner),
|
||||
InstallType::Pip => pipuv::install(app, agent, method, runner, false),
|
||||
InstallType::Uv => pipuv::install(app, agent, method, runner, true),
|
||||
InstallType::Cargo => cargo::install(app, agent, method, runner),
|
||||
InstallType::Go => golang::install(app, agent, method, runner),
|
||||
InstallType::Curl => script::install(app, agent, method, runner),
|
||||
InstallType::Binary => binary::install(app, agent, method, runner),
|
||||
InstallType::Git => git::install(app, agent, method, runner),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort "latest available version" for the method type.
|
||||
pub fn latest_version(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<Option<String>> {
|
||||
match method.kind {
|
||||
InstallType::Npm | InstallType::Bun => npm::latest(app, agent, method, runner),
|
||||
InstallType::Pip | InstallType::Uv => pipuv::latest(app, agent, method, runner),
|
||||
InstallType::Cargo => cargo::latest(app, agent, method, runner),
|
||||
InstallType::Binary => binary::latest(app, agent, method, runner),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Root directory for one agent's installation.
|
||||
pub fn install_root(app: &App, agent_name: &str) -> PathBuf {
|
||||
app.paths.install_dir.join(agent_name)
|
||||
}
|
||||
|
||||
/// Global bin directory (shims/executables, added to PATH).
|
||||
pub fn bin_dir(app: &App) -> PathBuf {
|
||||
app.paths.bin_dir.clone()
|
||||
}
|
||||
|
||||
/// Create a directory unless running in dry-run mode.
|
||||
pub fn ensure_dir(app: &App, path: &Path) -> Result<()> {
|
||||
if !app.dry_run() {
|
||||
std::fs::create_dir_all(path)
|
||||
.with_context(|| format!("cannot create {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a captured command, failing on a non-zero exit code.
|
||||
pub fn sh(
|
||||
runner: &dyn Runner,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<RunOutput> {
|
||||
let out = runner.run(cmd, args, env, cwd, true)?;
|
||||
if out.code != 0 {
|
||||
let mut detail = out.stderr.trim().to_string();
|
||||
if detail.is_empty() {
|
||||
detail = out.stdout.trim().to_string();
|
||||
}
|
||||
bail!(
|
||||
"command failed (exit code {}): {} {}
|
||||
{}",
|
||||
out.code,
|
||||
cmd,
|
||||
args.join(" "),
|
||||
detail
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run a command with inherited stdio, failing on a non-zero exit code.
|
||||
pub fn sh_inherit(
|
||||
runner: &dyn Runner,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let out = runner.run(cmd, args, env, cwd, false)?;
|
||||
if out.code != 0 {
|
||||
bail!(
|
||||
"command failed (exit code {}): {} {}",
|
||||
out.code,
|
||||
cmd,
|
||||
args.join(" ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Require a tool on the PATH, with an actionable error message.
|
||||
pub fn require_tool(runner: &dyn Runner, tool: &str, hint: &str) -> Result<PathBuf> {
|
||||
runner
|
||||
.which(tool)
|
||||
.ok_or_else(|| anyhow!("'{tool}' not found on PATH — install it first ({hint})"))
|
||||
}
|
||||
|
||||
/// Detect a binary's version via --version, then -V.
|
||||
pub fn detect_version(runner: &dyn Runner, bin: &Path) -> Option<String> {
|
||||
let empty = BTreeMap::new();
|
||||
if let Ok(out) = runner.run(&bin.display().to_string(), &["--version".to_string()], &empty, None, true)
|
||||
{
|
||||
if out.code == 0 {
|
||||
let combined = format!("{}
|
||||
{}", out.stdout, out.stderr);
|
||||
if let Some(v) = crate::version::find_version(&combined) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(out) = runner.run(&bin.display().to_string(), &["-V".to_string()], &empty, None, true) {
|
||||
if out.code == 0 {
|
||||
let combined = format!("{}
|
||||
{}", out.stdout, out.stderr);
|
||||
return crate::version::find_version(&combined);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Executable name with the platform suffix.
|
||||
pub fn exe_name(name: &str) -> String {
|
||||
format!("{name}{EXE_SUFFIX}")
|
||||
}
|
||||
|
||||
/// Names the installed binaries should match: the binary hint, the run
|
||||
/// command's first token, and the agent name.
|
||||
pub fn expected_bin_names(agent: &AgentDef, method: &MethodSpec) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
if let Some(b) = &method.binary {
|
||||
names.push(b.clone());
|
||||
}
|
||||
if let Some(t) = agent.first_token() {
|
||||
if !names.iter().any(|n| n.eq_ignore_ascii_case(&t)) {
|
||||
names.push(t);
|
||||
}
|
||||
}
|
||||
if !names.iter().any(|n| n == &agent.name) {
|
||||
names.push(agent.name.clone());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Snapshot a directory: file name -> full path.
|
||||
pub fn snapshot_dir(dir: &Path) -> BTreeMap<String, PathBuf> {
|
||||
let mut map = BTreeMap::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_file() {
|
||||
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
|
||||
map.insert(name.to_string(), p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Entries present in "after" but not in "before".
|
||||
pub fn diff_dir(
|
||||
before: &BTreeMap<String, PathBuf>,
|
||||
after: &BTreeMap<String, PathBuf>,
|
||||
) -> Vec<PathBuf> {
|
||||
after
|
||||
.iter()
|
||||
.filter(|(k, _)| !before.contains_key(*k))
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files in dir whose base name (sans extension) matches an expected name.
|
||||
/// On Windows, .cmd/.bat shims are preferred over bare scripts.
|
||||
pub fn find_bins(dir: &Path, expected: &[String]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return out;
|
||||
};
|
||||
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for p in entries {
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let stripped = name
|
||||
.strip_suffix(".exe")
|
||||
.or_else(|| name.strip_suffix(".cmd"))
|
||||
.or_else(|| name.strip_suffix(".bat"))
|
||||
.or_else(|| name.strip_suffix(".ps1"))
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
if expected.iter().any(|e| e.eq_ignore_ascii_case(&stripped)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
// Prefer a launchable shim for this platform.
|
||||
out.sort_by_key(|p| {
|
||||
let name = p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if cfg!(windows) {
|
||||
if name.ends_with(".cmd") || name.ends_with(".bat") {
|
||||
0u8
|
||||
} else if name.ends_with(".exe") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
} else if name.ends_with(".cmd") || name.ends_with(".exe") || name.ends_with(".ps1") {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
pub fn now_rfc3339() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
/// Build the state entry recorded after a successful install.
|
||||
pub fn make_entry(
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
outcome: &InstallOutcome,
|
||||
app: &App,
|
||||
) -> InstalledEntry {
|
||||
InstalledEntry {
|
||||
name: agent.name.clone(),
|
||||
version: outcome.version.clone().or_else(|| agent.version.clone()),
|
||||
method: method.kind.as_str().to_string(),
|
||||
install_dir: install_root(app, &agent.name).display().to_string(),
|
||||
bins: outcome.bins.iter().map(|b| b.display().to_string()).collect(),
|
||||
run: agent.run.clone().unwrap_or_default(),
|
||||
installed_at: now_rfc3339(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user