Compare commits

...
1 Commits
6 changed files with 212 additions and 10 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.5"
version = "0.2.6"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -90,7 +90,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am install <agent> | installe (dépendances vérifiées et proposées) |
| am install <agent> --method pip | choisit une méthode d'installation |
| am update <agent> | met à jour vers la dernière version connue |
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) |
| am uninstall <agent> | 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
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -50,10 +50,11 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
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(),
+206 -5
View File
@@ -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<i32> {
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<i32> {
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.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<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());
}
}