Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc38cd59e5 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user