Files
agent-manager/src/commands/uninstall_cmd.rs
T

322 lines
10 KiB
Rust

//! 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<i32> {
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<Vec<String>> {
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<i32> {
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<AgentDef>,
}
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());
}
}