//! 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 crate::events::{Event, EventKind}; 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 { return uninstall_external(app, agent); }; // Stop it first when running. if let Some(pid) = entry.pid { if crate::process::is_running(pid) { app.log.info(&format!("stopping running agent (pid {pid})")); if app.dry_run() { app.log.dry(format!("would stop pid {pid}")); } else { crate::process::stop_pid(pid, false, app.config.settings.stop_timeout()); app.state.update_pid(&agent.name, None, None)?; } } } let dir = PathBuf::from(&entry.install_dir); if app.dry_run() { app.log.dry(format!("would remove {}", dir.display())); } else { if dir.exists() { std::fs::remove_dir_all(&dir) .with_context(|| format!("cannot remove {}", dir.display()))?; } for b in &entry.bins { let p = PathBuf::from(b); if p.is_file() { std::fs::remove_file(&p) .with_context(|| format!("cannot remove {}", p.display()))?; } } app.state.remove(&agent.name)?; } if purge { let log = crate::process::agent_log_path(app, &agent.name); if app.dry_run() { app.log.dry(format!("would remove log {}", log.display())); } else if log.exists() { std::fs::remove_file(&log).ok(); } remove_from_user_config(app, &agent.name)?; } app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone())); app.log.success(&format!( "uninstalled {} (files removed{})", agent.title(), if purge { ", config purged" } else { "" } )); 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.emit( &Event::now(EventKind::Uninstall) .with_agent(agent.name.clone()) .with_reason("external"), ); 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 { app.log.verbose("no user config file — nothing to purge"); return Ok(()); }; let is_json = path.extension().map(|e| e == "json").unwrap_or(false); if is_json { app.log .warn("config purge is not supported for JSON config files"); return Ok(()); } let text = std::fs::read_to_string(path) .with_context(|| format!("cannot read {}", path.display()))?; let mut value: serde_yaml::Value = serde_yaml::from_str(&text) .with_context(|| format!("cannot parse {}", path.display()))?; let mut removed = false; if let serde_yaml::Value::Mapping(map) = &mut value { if let Some(serde_yaml::Value::Sequence(seq)) = map.get_mut("agents") { let before = seq.len(); seq.retain(|v| { v.get("name") .and_then(|n| n.as_str()) .map(|n| n != name) .unwrap_or(true) }); removed = seq.len() != before; } } if !removed { app.log.verbose(&format!("'{name}' not defined in {}", path.display())); return Ok(()); } let backup = path.with_extension("yaml.bak"); std::fs::copy(path, &backup) .with_context(|| format!("cannot create backup {}", backup.display()))?; std::fs::write(path, serde_yaml::to_string(&value)?) .with_context(|| format!("cannot write {}", path.display()))?; app.log.info(&format!( "removed '{name}' from {} (backup: {})", path.display(), backup.display() )); 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()); } }