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:
+325
@@ -0,0 +1,325 @@
|
||||
//! External agent detection: PATH lookup for unmanaged agents, version
|
||||
//! probing in parallel (one thread per binary), and an on-disk cache keyed
|
||||
//! by the PATH string and by binary identity (path + mtime + size), so
|
||||
//! repeated runs never re-scan the PATH or re-probe versions.
|
||||
|
||||
use crate::app::App;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExternalInfo {
|
||||
/// agent name -> binary path (catalog agents found on PATH, unmanaged).
|
||||
pub paths: BTreeMap<String, PathBuf>,
|
||||
/// agent name -> detected version (None = unknown).
|
||||
pub versions: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl ExternalInfo {
|
||||
pub fn path_of(&self, name: &str) -> Option<&PathBuf> {
|
||||
self.paths.get(name)
|
||||
}
|
||||
|
||||
pub fn version_of(&self, name: &str) -> Option<&Option<String>> {
|
||||
self.versions.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the version of an external binary (--version), bounded by a
|
||||
/// timeout so a misbehaving agent can never hang the whole listing.
|
||||
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
|
||||
let mut cmd = std::process::Command::new(&prog);
|
||||
cmd.args(&prefix)
|
||||
.arg("--version")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let Ok(mut child) = cmd.spawn() else {
|
||||
return None;
|
||||
};
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
|
||||
Ok(Some(s)) => Some(s),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let Some(status) = status else {
|
||||
return None;
|
||||
};
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut o) = child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut stdout);
|
||||
}
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
let _ = e.read_to_string(&mut stderr);
|
||||
}
|
||||
let combined = format!("{stdout}
|
||||
{stderr}");
|
||||
crate::version::find_version(&combined)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// On-disk cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ProbeCache {
|
||||
/// FNV-1a hash of the PATH string the token index was built from.
|
||||
#[serde(default)]
|
||||
path_hash: u64,
|
||||
/// Catalog run-command first token -> resolved path (null = not found).
|
||||
#[serde(default)]
|
||||
tokens: BTreeMap<String, Option<String>>,
|
||||
/// Binary identity key -> detected version.
|
||||
#[serde(default)]
|
||||
versions: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
fn path_hash() -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
if let Some(p) = std::env::var_os("PATH") {
|
||||
for b in p.to_string_lossy().bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Build an index of every executable on the PATH in a single pass.
|
||||
/// Much cheaper than one which() call per agent when the catalog is large.
|
||||
fn build_path_index() -> BTreeMap<String, PathBuf> {
|
||||
let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return map;
|
||||
};
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let lower = name.to_lowercase();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let stem = if lower.ends_with(".exe") {
|
||||
lower[..lower.len() - 4].to_string()
|
||||
} else if lower.ends_with(".cmd") {
|
||||
lower[..lower.len() - 4].to_string()
|
||||
} else if lower.ends_with(".bat") {
|
||||
lower[..lower.len() - 4].to_string()
|
||||
} else if lower.ends_with(".com") {
|
||||
lower[..lower.len() - 4].to_string()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
map.entry(stem).or_insert(p);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let executable = std::fs::metadata(&p)
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false);
|
||||
if executable {
|
||||
map.entry(lower).or_insert(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Detect every unmanaged catalog agent present on the PATH.
|
||||
///
|
||||
/// The PATH is scanned once and its result cached (invalidated by the PATH
|
||||
/// string hash); version probes run in parallel and are cached by binary
|
||||
/// identity. A steady-state run does no PATH scanning and no subprocess.
|
||||
pub fn detect_externals(app: &App) -> ExternalInfo {
|
||||
let managed: BTreeMap<String, crate::state::InstalledEntry> =
|
||||
app.state.all().unwrap_or_default();
|
||||
let cache_path = app.paths.probe_cache_file.clone();
|
||||
let mut cache: ProbeCache = std::fs::read_to_string(&cache_path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default();
|
||||
let mut dirty = false;
|
||||
|
||||
// Resolve the run-command tokens of the catalog (once per PATH change).
|
||||
let current_hash = path_hash();
|
||||
if cache.path_hash != current_hash {
|
||||
let index = build_path_index();
|
||||
cache.tokens.clear();
|
||||
for agent in app.catalog.agents() {
|
||||
let Some(token) = agent.first_token() else {
|
||||
continue;
|
||||
};
|
||||
if !cache.tokens.contains_key(&token) {
|
||||
let found = index
|
||||
.get(&token.to_lowercase())
|
||||
.map(|p| p.display().to_string());
|
||||
cache.tokens.insert(token, found);
|
||||
}
|
||||
}
|
||||
cache.path_hash = current_hash;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
let mut info = ExternalInfo::default();
|
||||
// Stale cached paths (binary removed since the cache was built) force a
|
||||
// rebuild of the token index.
|
||||
let stale = cache
|
||||
.tokens
|
||||
.values()
|
||||
.flatten()
|
||||
.any(|p| !Path::new(p).exists());
|
||||
if stale {
|
||||
let index = build_path_index();
|
||||
cache.tokens.clear();
|
||||
for agent in app.catalog.agents() {
|
||||
let Some(token) = agent.first_token() else {
|
||||
continue;
|
||||
};
|
||||
if !cache.tokens.contains_key(&token) {
|
||||
let found = index
|
||||
.get(&token.to_lowercase())
|
||||
.map(|p| p.display().to_string());
|
||||
cache.tokens.insert(token, found);
|
||||
}
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
for agent in app.catalog.agents() {
|
||||
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
|
||||
continue;
|
||||
}
|
||||
let Some(token) = agent.first_token() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(Some(path)) = cache.tokens.get(&token) {
|
||||
info.paths.insert(agent.name.clone(), PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
|
||||
// Versions: on-disk cache first, then parallel probes for the misses.
|
||||
let mut missing: Vec<(String, PathBuf)> = Vec::new();
|
||||
for (name, bin) in &info.paths {
|
||||
let key = bin_key(bin);
|
||||
match cache.versions.get(&key) {
|
||||
Some(v) => {
|
||||
info.versions.insert(name.clone(), v.clone());
|
||||
}
|
||||
None => missing.push((name.clone(), bin.clone())),
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
let results = std::sync::Mutex::new(Vec::new());
|
||||
std::thread::scope(|s| {
|
||||
for (name, bin) in &missing {
|
||||
let results = &results;
|
||||
let name = name.clone();
|
||||
let bin = bin.clone();
|
||||
s.spawn(move || {
|
||||
let version = detect_external_version(&bin);
|
||||
results.lock().unwrap().push((name, bin, version));
|
||||
});
|
||||
}
|
||||
});
|
||||
for (name, bin, version) in results.into_inner().unwrap_or_default() {
|
||||
let key = bin_key(&bin);
|
||||
cache.versions.insert(key.clone(), version.clone());
|
||||
info.versions.insert(name, version);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
// Keep the caches bounded.
|
||||
if cache.versions.len() > 500 {
|
||||
cache.versions.clear();
|
||||
dirty = true;
|
||||
}
|
||||
if dirty {
|
||||
if let Ok(text) = serde_json::to_string_pretty(&cache) {
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(&cache_path, text);
|
||||
}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
/// Identity key for a binary: path + modification time + size, so a stale
|
||||
/// cache entry is never reused after the binary changed.
|
||||
fn bin_key(bin: &Path) -> String {
|
||||
let meta = std::fs::metadata(bin).ok();
|
||||
let mtime = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let size = meta.map(|m| m.len()).unwrap_or(0);
|
||||
format!("{}|{mtime}|{size}", bin.display())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bin_key_is_stable_and_distinct() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.exe");
|
||||
std::fs::write(&a, b"abc").unwrap();
|
||||
let k1 = bin_key(&a);
|
||||
let k2 = bin_key(&a);
|
||||
assert_eq!(k1, k2);
|
||||
std::fs::write(&a, b"abcd").unwrap();
|
||||
let k3 = bin_key(&a);
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_cache_roundtrip() {
|
||||
let mut cache = ProbeCache::default();
|
||||
cache
|
||||
.versions
|
||||
.insert("C:/x|1|2".to_string(), Some("1.2.3".to_string()));
|
||||
cache.tokens.insert("node".to_string(), None);
|
||||
let text = serde_json::to_string(&cache).unwrap();
|
||||
let back: ProbeCache = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(
|
||||
back.versions.get("C:/x|1|2").unwrap().as_deref(),
|
||||
Some("1.2.3")
|
||||
);
|
||||
assert!(back.tokens.contains_key("node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_cache_format_is_ignored() {
|
||||
// The previous cache format had only an "entries" field; loading it
|
||||
// must not fail.
|
||||
let text = r#"{ "entries": { "a": "1.0" } }"#;
|
||||
let back: ProbeCache = serde_json::from_str(text).unwrap();
|
||||
assert!(back.versions.is_empty());
|
||||
assert!(back.tokens.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user