Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc38cd59e5 | ||
|
|
61f4909bc6 | ||
|
|
98aa75d185 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.4"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.4"
|
||||
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
|
||||
@@ -211,7 +211,11 @@ qu'agent-manager possède :
|
||||
# macOS
|
||||
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
|
||||
|
||||
# Windows (cmd)
|
||||
# Windows — PowerShell
|
||||
Remove-Item -Recurse -Force $env:LOCALAPPDATA\agent-manager, $env:APPDATA\agent-manager -ErrorAction SilentlyContinue
|
||||
|
||||
# Windows — cmd.exe (à ne PAS coller dans PowerShell : rmdir y est un
|
||||
# alias de Remove-Item et le & final lance un job en arrière-plan)
|
||||
rmdir /s /q "%LOCALAPPDATA%\agent-manager" & rmdir /s /q "%APPDATA%\agent-manager"
|
||||
|
||||
(Si vous utilisiez `AGENT_MANAGER_DATA`/`AGENT_MANAGER_STATE`/`AGENT_MANAGER_CONFIG_DIR`,
|
||||
|
||||
+13
@@ -56,6 +56,19 @@ agents:
|
||||
run: claude
|
||||
tags: [anthropic, assistant, source-available, mainstream]
|
||||
|
||||
- name: deepseek-harness
|
||||
display_name: "DeepSeek Harness"
|
||||
description: "CLI officiel DeepSeek (dsh) : boot de profils, gestion des plugins Cordis, orchestration multi-agents, boucles de buts (goals) et interface web. Installez-le pour piloter vos agents depuis am."
|
||||
category: coding-agent
|
||||
website: https://github.com/deepseek-ai/deepseek-harness
|
||||
install:
|
||||
type: npm
|
||||
package: "@deepseek-ai/dsh"
|
||||
dependencies:
|
||||
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
|
||||
run: dsh
|
||||
tags: [deepseek, harness, multi-agent, plugins, web-ui]
|
||||
|
||||
- name: hermes-agent
|
||||
display_name: "Hermes Agent"
|
||||
description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)."
|
||||
|
||||
+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,
|
||||
|
||||
+134
-48
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::output::print_json;
|
||||
use colored::Colorize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -101,51 +102,102 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
println!("{}", agent.title());
|
||||
if agent.name != agent.title() {
|
||||
println!(" slug: {}", agent.name);
|
||||
}
|
||||
if let Some(d) = &agent.description {
|
||||
println!(" description: {d}");
|
||||
}
|
||||
let color = app.color();
|
||||
let dim = |s: &str| -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let bold = |s: &str| -> String {
|
||||
if color {
|
||||
s.bold().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// --- Header -------------------------------------------------------------
|
||||
println!("{}", bold(agent.title()));
|
||||
let mut sub: Vec<String> = vec![agent.name.clone()];
|
||||
if let Some(c) = &agent.category {
|
||||
println!(" category: {c}");
|
||||
sub.push(c.clone());
|
||||
}
|
||||
println!(" {}", dim(&sub.join(" · ")));
|
||||
if let Some(d) = &agent.description {
|
||||
println!();
|
||||
println!(" {d}");
|
||||
}
|
||||
if let Some(w) = &agent.website {
|
||||
println!(" website: {w}");
|
||||
}
|
||||
if !agent.tags.is_empty() {
|
||||
println!(" tags: {}", agent.tags.join(", "));
|
||||
}
|
||||
if !agent.platforms.is_empty() {
|
||||
println!(" platforms: {}", agent.platforms.join(", "));
|
||||
println!();
|
||||
println!(" {} {}", dim("website"), w);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Status block
|
||||
let status = if running {
|
||||
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
|
||||
// --- Status --------------------------------------------------------------
|
||||
let external = external_path(agent);
|
||||
let (status_word, detail): (&str, String) = if running {
|
||||
let pid = entry.as_ref().and_then(|e| e.pid).unwrap_or(0);
|
||||
("running", format!(" (pid {pid})"))
|
||||
} else if entry.is_some() {
|
||||
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
|
||||
let v = entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.version.clone())
|
||||
.map(|v| format!(" — v{v}"))
|
||||
.unwrap_or_default();
|
||||
("installed", v)
|
||||
} else if !agent.installable {
|
||||
"not installable locally".to_string()
|
||||
} else if external_path(agent).is_some() {
|
||||
"found on PATH (external — not managed)".to_string()
|
||||
("not-installable", String::new())
|
||||
} else if external.is_some() {
|
||||
("external", " (found on PATH — not managed)".to_string())
|
||||
} else {
|
||||
"not installed".to_string()
|
||||
("not-installed", String::new())
|
||||
};
|
||||
println!(" status: {status}");
|
||||
let word = crate::output::status_style(status_word, color);
|
||||
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
|
||||
|
||||
// Version: recorded for managed installs, detected for external ones.
|
||||
let version_display: Option<String> = entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.version.clone())
|
||||
.or_else(|| external.as_ref().and_then(|p| crate::probe::detect_external_version(p)));
|
||||
if let Some(v) = version_display {
|
||||
println!(" {} {}", dim("● version"), v);
|
||||
}
|
||||
if let Some(t) = entry.as_ref().map(|e| e.installed_at.clone()) {
|
||||
println!(" {} {}", dim("● installed"), t);
|
||||
}
|
||||
if let Some(t) = entry.as_ref().and_then(|e| e.updated_at.clone()) {
|
||||
println!(" {} {}", dim("● updated"), t);
|
||||
}
|
||||
if let Some(p) = entry
|
||||
.as_ref()
|
||||
.map(|e| e.install_dir.clone())
|
||||
.or_else(|| external.clone().map(|p| p.display().to_string()))
|
||||
{
|
||||
println!(" {} {}", dim("● path"), p);
|
||||
}
|
||||
if let Some(e) = &entry {
|
||||
if let Some(bin) = e.bins.first() {
|
||||
println!(" {} {}", dim("● bin"), bin);
|
||||
}
|
||||
}
|
||||
|
||||
// --- How to run it -------------------------------------------------------
|
||||
if let Some(run) = &agent.run {
|
||||
let args = if agent.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", agent.args.join(" "))
|
||||
};
|
||||
println!(" run: {run}{args}");
|
||||
println!();
|
||||
println!(" {} {}", dim("▶ run"), format!("{run}{args}"));
|
||||
}
|
||||
if !agent.env.is_empty() {
|
||||
println!(
|
||||
" env: {}",
|
||||
" {} {}",
|
||||
dim("⚙ env"),
|
||||
agent
|
||||
.env
|
||||
.iter()
|
||||
@@ -155,38 +207,72 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Installation ---------------------------------------------------------
|
||||
if let Some(spec) = &agent.install {
|
||||
println!(" install methods:");
|
||||
println!();
|
||||
println!(" {}", bold("install"));
|
||||
for (i, m) in spec.all_methods().iter().enumerate() {
|
||||
let note = m
|
||||
.note
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default();
|
||||
println!(" {}) {}{}", i + 1, describe_method(m), note);
|
||||
println!(" {}) {}", i + 1, describe_method(m));
|
||||
if let Some(n) = &m.note {
|
||||
println!(" {}", dim(n));
|
||||
}
|
||||
}
|
||||
if !spec.post_install.is_empty() {
|
||||
println!(" post-install: {}", spec.post_install.join(" && "));
|
||||
println!(
|
||||
" {}",
|
||||
dim(&format!("post-install: {}", spec.post_install.join(" && ")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dependencies, checked live ------------------------------------------
|
||||
if !agent.dependencies.is_empty() {
|
||||
println!(" dependencies:");
|
||||
for d in &agent.dependencies {
|
||||
let min = d
|
||||
.min_version
|
||||
.as_deref()
|
||||
.map(|m| format!(" >= {m}"))
|
||||
.unwrap_or_default();
|
||||
let hint = d
|
||||
.install_hint
|
||||
.as_deref()
|
||||
.map(|h| format!(" — {h}"))
|
||||
.unwrap_or_default();
|
||||
println!(" - {}{}{}", d.name, min, hint);
|
||||
println!();
|
||||
println!(" {}", bold("dependencies"));
|
||||
let runner = crate::runner::SystemRunner::new(false, app.cli.verbose, &app.log);
|
||||
let checks = crate::deps::check_agent(agent, &runner);
|
||||
for c in &checks {
|
||||
let mark = if c.ok { "✓" } else { "✗" };
|
||||
let mark = if color {
|
||||
if c.ok {
|
||||
mark.green().to_string()
|
||||
} else {
|
||||
mark.red().to_string()
|
||||
}
|
||||
} else {
|
||||
mark.to_string()
|
||||
};
|
||||
let mut line = format!(" {mark} {}", c.name);
|
||||
if let Some(v) = &c.version {
|
||||
line.push_str(&format!(" {v}"));
|
||||
}
|
||||
if let Some(min) = &c.min {
|
||||
line.push_str(&format!(" (min {min})"));
|
||||
}
|
||||
if !c.ok {
|
||||
if let Some(hint) = agent
|
||||
.dependencies
|
||||
.iter()
|
||||
.find(|d| d.name == c.name)
|
||||
.and_then(|d| d.install_hint.clone())
|
||||
{
|
||||
line.push_str(&format!(" — {hint}"));
|
||||
} else if !c.detail.is_empty() && c.detail != "not found on PATH" {
|
||||
line.push_str(&format!(" — {}", c.detail));
|
||||
}
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tags and note ---------------------------------------------------------
|
||||
if !agent.tags.is_empty() {
|
||||
println!();
|
||||
println!(" {}", dim(&format!("tags {}", agent.tags.join(" · "))));
|
||||
}
|
||||
if let Some(n) = &agent.note {
|
||||
println!(" note: {n}");
|
||||
println!();
|
||||
println!(" {}", dim(&format!("note {n}")));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+177
-10
@@ -9,6 +9,10 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(windows)]
|
||||
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
|
||||
#[cfg(windows)]
|
||||
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
|
||||
#[cfg(windows)]
|
||||
const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFF_FFFF;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[link(name = "Kernel32")]
|
||||
@@ -18,6 +22,8 @@ extern "system" {
|
||||
lpNewFileName: *const u16,
|
||||
dwFlags: u32,
|
||||
) -> i32;
|
||||
fn GetFileAttributesW(lpFileName: *const u16) -> u32;
|
||||
fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32;
|
||||
}
|
||||
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
@@ -67,30 +73,150 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
// Remove everything first; any message logged *after* deleting the
|
||||
// state directory would recreate the log folder, so the removal
|
||||
// itself is silent and the report below goes to plain stdout.
|
||||
// The removal is tolerant: read-only attributes are cleared and files
|
||||
// locked by other processes (for example the running am.exe when it
|
||||
// lives inside the data directory) are scheduled for deletion at the
|
||||
// next reboot instead of failing the whole uninstall.
|
||||
let mut locked: Vec<PathBuf> = Vec::new();
|
||||
for (_, path) in &roots {
|
||||
std::fs::remove_dir_all(path)
|
||||
.with_context(|| format!("cannot remove {}", path.display()))?;
|
||||
remove_tree(path, &mut locked);
|
||||
}
|
||||
for (_, path) in &roots {
|
||||
println!("{} {}", "removed:".green(), path.display());
|
||||
if !path.exists() {
|
||||
println!("{} {}", "removed:".green(), path.display());
|
||||
}
|
||||
}
|
||||
if !locked.is_empty() {
|
||||
let scheduled = schedule_locked(&locked);
|
||||
if scheduled.len() == locked.len() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
"locked:".yellow(),
|
||||
locked.len()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s), {} planifiée(s) au prochain redémarrage",
|
||||
"locked:".yellow(),
|
||||
locked.len(),
|
||||
scheduled.len()
|
||||
);
|
||||
for path in &locked {
|
||||
if !scheduled.contains(path) {
|
||||
eprintln!(
|
||||
"{} {} (supprimez-le manuellement ou relancez en administrateur)",
|
||||
"kept:".red(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the executable itself.
|
||||
// 3. Delete the executable itself, then sweep the directories left
|
||||
// empty behind it.
|
||||
let exe = std::env::current_exe()
|
||||
.with_context(|| "cannot locate the agent-manager executable")?;
|
||||
remove_self(&exe)?;
|
||||
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||
remove_self(&exe, &cleanup_roots)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Recursively remove a tree, tolerating locked or read-only entries.
|
||||
/// Paths that could not be removed are collected into `locked` so the
|
||||
/// caller can schedule their deletion at the next reboot.
|
||||
fn remove_tree(path: &Path, locked: &mut Vec<PathBuf>) {
|
||||
let meta = match std::fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
if meta.is_dir() {
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
remove_tree(&entry.path(), locked);
|
||||
}
|
||||
}
|
||||
clear_readonly(path);
|
||||
if std::fs::remove_dir(path).is_err() {
|
||||
locked.push(path.to_path_buf());
|
||||
}
|
||||
} else {
|
||||
clear_readonly(path);
|
||||
if std::fs::remove_file(path).is_err() {
|
||||
locked.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the read-only attribute (a common cause of "Access denied" on
|
||||
/// Windows, e.g. files extracted by Expand-Archive).
|
||||
#[cfg(windows)]
|
||||
fn clear_readonly(path: &Path) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let wide: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
unsafe {
|
||||
let attrs = GetFileAttributesW(wide.as_ptr());
|
||||
if attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY) != 0 {
|
||||
SetFileAttributesW(wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn clear_readonly(_path: &Path) {}
|
||||
|
||||
/// Schedule every locked path for deletion at the next reboot (Windows).
|
||||
/// Returns the paths that could be scheduled.
|
||||
#[cfg(windows)]
|
||||
fn schedule_locked(locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut scheduled = Vec::new();
|
||||
for path in locked {
|
||||
let wide: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let ok = unsafe {
|
||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||
};
|
||||
if ok {
|
||||
scheduled.push(path.clone());
|
||||
}
|
||||
}
|
||||
scheduled
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Delete the running executable. On Unix the file can be unlinked while it
|
||||
/// runs; on Windows a detached cmd re-tries the deletion right after this
|
||||
/// process exits (the file is locked while we run).
|
||||
fn remove_self(exe: &Path) -> Result<()> {
|
||||
/// runs; on Windows a hidden cmd re-tries the deletion right after this
|
||||
/// process exits (the file is locked while we run). Empty directories left
|
||||
/// behind under the removed roots are swept afterwards.
|
||||
fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::fs::remove_file(exe)
|
||||
.with_context(|| format!("cannot remove {}", exe.display()))?;
|
||||
// Sweep empty directories upward, but never above a removed root.
|
||||
let mut dir = exe.parent();
|
||||
while let Some(d) = dir {
|
||||
if !roots.iter().any(|r| d.starts_with(r)) {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_dir(d).is_err() {
|
||||
break;
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed:".green(),
|
||||
@@ -103,10 +229,20 @@ fn remove_self(exe: &Path) -> Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
// Immediate deletion: a hidden cmd retries for a few seconds, because
|
||||
// the executable is locked while we are still running.
|
||||
let script = format!(
|
||||
"for /L %i in (1,1,20) do @(del /f /q \"{0}\" >nul 2>&1 & if not exist \"{0}\" exit /b 0 & ping -n 2 127.0.0.1 >nul)",
|
||||
// Once the file is gone, the now-empty directories (parent, then the
|
||||
// removed roots) are swept — rd /q only ever removes empty dirs.
|
||||
let mut script = format!(
|
||||
"for /L %i in (1,1,20) do @if exist \"{0}\" (del /f /q \"{0}\" >nul 2>&1 & ping -n 2 127.0.0.1 >nul)",
|
||||
exe.display()
|
||||
);
|
||||
if let Some(parent) = exe.parent() {
|
||||
if roots.iter().any(|r| parent.starts_with(r)) {
|
||||
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", parent.display()));
|
||||
}
|
||||
}
|
||||
for root in roots {
|
||||
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", root.display()));
|
||||
}
|
||||
let spawned = std::process::Command::new("cmd")
|
||||
.args(["/C", &script])
|
||||
.spawn()
|
||||
@@ -142,3 +278,34 @@ fn remove_self(exe: &Path) -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removes_readonly_tree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
let file = sub.join("readonly.txt");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
// Read-only files are a classic cause of "Access denied" on Windows.
|
||||
let mut perms = std::fs::metadata(&file).unwrap().permissions();
|
||||
#[allow(clippy::permissions_set_readonly_false)]
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(&file, perms).unwrap();
|
||||
|
||||
let mut locked: Vec<PathBuf> = Vec::new();
|
||||
remove_tree(dir.path(), &mut locked);
|
||||
assert!(!dir.path().exists(), "tree should be fully removed");
|
||||
assert!(locked.is_empty(), "unexpected locked paths: {locked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_missing_tree() {
|
||||
let mut locked: Vec<PathBuf> = Vec::new();
|
||||
remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked);
|
||||
assert!(locked.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -624,6 +624,7 @@ mod tests {
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len());
|
||||
assert!(cfg.agents.iter().any(|a| a.name == "claude-code"));
|
||||
assert!(cfg.agents.iter().any(|a| a.name == "deepseek-harness"));
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
|
||||
+17
-11
@@ -652,37 +652,40 @@ fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<boo
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
handle_line(app, session, line)?;
|
||||
Ok(false)
|
||||
handle_line(app, session, line)
|
||||
}
|
||||
|
||||
fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<()> {
|
||||
/// Handle one line of input. Returns true when the session should exit
|
||||
/// (only after a successful self-uninstall: the binary is gone).
|
||||
fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<bool> {
|
||||
// '!' forces system execution even for words that look like am commands.
|
||||
if let Some(rest) = line.strip_prefix('!') {
|
||||
let rest = rest.trim();
|
||||
if !rest.is_empty() {
|
||||
return run_system(app, session, rest);
|
||||
run_system(app, session, rest)?;
|
||||
}
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
|
||||
if tokens.is_empty() {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
let cmd = tokens[0].as_str();
|
||||
let rest = &tokens[1..];
|
||||
|
||||
// Session directory change: kept in the am process, not a child shell.
|
||||
if matches!(cmd, "cd" | "chdir") {
|
||||
return change_dir(rest);
|
||||
change_dir(rest)?;
|
||||
return Ok(false);
|
||||
}
|
||||
if cmd == "shell" {
|
||||
shell_command(session, rest.first().map(|s| s.as_str()));
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
// Everything that is not an am command is a system command.
|
||||
if !is_am_command(cmd) {
|
||||
return run_system(app, session, line);
|
||||
run_system(app, session, line)?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let need = |what: &str| -> Result<String> {
|
||||
@@ -786,11 +789,14 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<()>
|
||||
other => {
|
||||
app.log
|
||||
.error(&format!("unknown command '{other}' — type 'help'"));
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
// After a successful self-uninstall the executable is scheduled for
|
||||
// deletion: leave the session instead of pretending nothing happened.
|
||||
let exit_after = matches!(cmd, "self-uninstall");
|
||||
commands::execute_command(app, &command)?;
|
||||
Ok(())
|
||||
Ok(exit_after)
|
||||
}
|
||||
|
||||
/// Whether a word is one of the am commands (everything else is executed by
|
||||
|
||||
Reference in New Issue
Block a user