From bc38cd59e57da17b908d997589586fa94b1f7276 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 15 Aug 2026 22:20:51 -0400 Subject: [PATCH] v0.2.6 : colonne AGENT (slug) dans am list + desinstallation des agents externes (npm/pip/uv/cargo/bun ou suppression des fichiers, shims .cmd gerees) --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- src/cli.rs | 2 +- src/commands/list_cmd.rs | 3 +- src/commands/uninstall_cmd.rs | 211 +++++++++++++++++++++++++++++++++- 6 files changed, 212 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5549842..9dda779 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 57627b8..6099b2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.5" +version = "0.2.6" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/README.md b/README.md index 0c93e6b..02bae04 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) | am install | installe (dépendances vérifiées et proposées) | | am install --method pip | choisit une méthode d'installation | | am update | met à jour vers la dernière version connue | -| am uninstall | désinstalle et nettoie (--purge : logs + config) | +| am uninstall | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) | | am export / am import | sauvegarde et restaure config + état | ### 🚀 Exécuter diff --git a/src/cli.rs b/src/cli.rs index 996222a..7ca9fb5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -102,7 +102,7 @@ pub enum Command { #[arg(long)] force: bool, }, - /// Uninstall an agent and clean its files + /// Uninstall an agent (managed or external) and clean its files Uninstall { /// Agent name or alias agent: String, diff --git a/src/commands/list_cmd.rs b/src/commands/list_cmd.rs index 152bf35..0a7f59d 100644 --- a/src/commands/list_cmd.rs +++ b/src/commands/list_cmd.rs @@ -50,10 +50,11 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result { return Ok(0); } let color = app.color(); - let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]); + let mut table = Table::new(vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"]); for r in &rows { table.row(vec![ r.display_name.clone(), + r.name.clone(), r.version.clone().unwrap_or_else(|| "—".to_string()), status_style(&r.status, color), r.source.clone(), diff --git a/src/commands/uninstall_cmd.rs b/src/commands/uninstall_cmd.rs index 9983cc9..a09e19a 100644 --- a/src/commands/uninstall_cmd.rs +++ b/src/commands/uninstall_cmd.rs @@ -1,19 +1,19 @@ //! uninstall: stop the agent if needed, remove its files, bins and state //! entry; --purge also removes logs and the user-config definition. +//! External agents (installed outside agent-manager) are removed through +//! their package manager when the catalog knows it, or by deleting the +//! executables found on PATH otherwise. use super::*; use anyhow::{Context, Result}; use std::path::PathBuf; +use std::process::Command as ProcessCommand; pub fn run(app: &App, agent_name: &str, purge: bool) -> Result { let agent = require_agent(app, agent_name)?; let entry = app.state.get(&agent.name).ok().flatten(); let Some(entry) = entry else { - app.log.info(&format!( - "agent '{}' is not managed by agent-manager — nothing to uninstall", - agent.name - )); - return Ok(0); + return uninstall_external(app, agent); }; // Stop it first when running. @@ -65,6 +65,130 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result { Ok(0) } +/// Build the package-manager uninstall command for an external agent whose +/// install method is a known package manager and whose package is recorded. +fn package_plan(agent: &AgentDef) -> Option> { + use crate::config::InstallType; + let spec = agent.install.as_ref()?; + for m in spec.all_methods() { + let Some(pkg) = m.package.as_deref() else { + continue; + }; + let plan = match m.kind { + InstallType::Npm => { + vec!["npm", "uninstall", "--global", pkg] + } + InstallType::Pip => { + if which::which("pip").is_ok() { + vec!["pip", "uninstall", "-y", pkg] + } else if which::which("python3").is_ok() { + vec!["python3", "-m", "pip", "uninstall", "-y", pkg] + } else if which::which("python").is_ok() { + vec!["python", "-m", "pip", "uninstall", "-y", pkg] + } else { + continue; + } + } + InstallType::Uv => vec!["uv", "tool", "uninstall", pkg], + InstallType::Cargo => vec!["cargo", "uninstall", pkg], + InstallType::Bun => vec!["bun", "remove", "--global", pkg], + _ => continue, + }; + return Some(plan.into_iter().map(|s| s.to_string()).collect()); + } + None +} + +/// Best-effort removal of an agent installed outside agent-manager: first the +/// package-manager command when the catalog knows one, then the executable +/// files left on PATH (and their directory when it is dedicated to the +/// agent, e.g. AppData\Local\atomic-agent). +fn uninstall_external(app: &App, agent: &AgentDef) -> Result { + let first_token = agent.first_token(); + + // 1. Package-manager plan (npm, pip, uv, cargo, bun). + if let Some(tokens) = package_plan(agent) { + if which::which(&tokens[0]).is_ok() { + app.log.info(&format!( + "agent '{}' is external (installed outside agent-manager) — uninstall with: {}", + agent.name, + tokens.join(" ") + )); + if !app.confirm("Run this command?")? { + app.log.info("cancelled — nothing was uninstalled"); + return Ok(0); + } + if app.dry_run() { + app.log.dry(format!("would run: {}", tokens.join(" "))); + } else { + // resolve_program handles Windows .cmd/.bat shims (npm, + // pip, ...) which std::process::Command cannot launch. + let (prog, prefix) = crate::runner::resolve_program(&tokens[0]); + let mut args = prefix; + args.extend(tokens[1..].iter().cloned()); + let status = ProcessCommand::new(&prog) + .args(&args) + .status() + .with_context(|| format!("failed to run {}", tokens.join(" ")))?; + if !status.success() { + app.log.warn("the command failed — removing the files it left behind"); + } + } + } + } + + // 2. Whatever executable is still on PATH, delete it directly (and its + // directory when it belongs to the agent). + let still = first_token.as_deref().and_then(|t| which::which(t).ok()); + if let Some(path) = still { + let parent = path.parent().map(|p| p.to_path_buf()); + let dedicated = parent + .as_ref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name)) + .unwrap_or(false); + let target: PathBuf = if dedicated { + parent.clone().unwrap_or_else(|| path.clone()) + } else { + path.clone() + }; + app.log.info(&format!( + "remove leftover {} {}", + if dedicated { "directory" } else { "file" }, + target.display() + )); + if !app.confirm("Delete it?")? { + app.log.info(&format!("'{}' left in place", target.display())); + return Ok(0); + } + if app.dry_run() { + app.log.dry(format!("would remove {}", target.display())); + } else if dedicated { + std::fs::remove_dir_all(&target) + .with_context(|| format!("cannot remove {}", target.display()))?; + } else { + std::fs::remove_file(&target) + .with_context(|| format!("cannot remove {}", target.display()))?; + } + } + + // 3. Final verification. + let gone = first_token + .as_deref() + .and_then(|t| which::which(t).ok()) + .is_none(); + if gone { + app.log + .success(&format!("uninstalled {} (external)", agent.title())); + } else { + app.log.warn(&format!( + "{} is still present on PATH — remove it manually", + agent.title() + )); + } + Ok(0) +} + /// Remove the agent entry from the user config file (--purge). fn remove_from_user_config(app: &App, name: &str) -> Result<()> { let Some(path) = &app.config_origin else { @@ -111,3 +235,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + fn agent_from_yaml(yaml: &str) -> AgentDef { + #[derive(serde::Deserialize)] + struct Wrap { + agents: Vec, + } + let w: Wrap = serde_yaml::from_str(yaml).unwrap(); + w.agents.into_iter().next().unwrap() + } + + #[test] + fn package_plan_npm() { + let a = agent_from_yaml( + r#" +agents: + - name: x + install: + type: npm + package: "@scope/pkg" + run: x +"#, + ); + let plan = package_plan(&a).unwrap(); + assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]); + } + + #[test] + fn package_plan_cargo() { + let a = agent_from_yaml( + r#" +agents: + - name: x + install: + type: cargo + package: my-crate + run: x +"#, + ); + let plan = package_plan(&a).unwrap(); + assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]); + } + + #[test] + fn package_plan_binary_is_none() { + let a = agent_from_yaml( + r#" +agents: + - name: y + install: + type: binary + repo: owner/repo + binary: y + run: y +"#, + ); + assert!(package_plan(&a).is_none()); + } + + #[test] + fn package_plan_unknown_method_is_none() { + let a = agent_from_yaml( + r#" +agents: + - name: z + install: + type: git + repo: https://example.com/z.git + run: z +"#, + ); + assert!(package_plan(&a).is_none()); + } +} +