v0.2.5 : info enrichi (statut, versions, dependances verifiees) + self-uninstall tolerant (fichiers verrouilles / lecture seule, balayage des dossiers vides) + agent deepseek-harness au catalogue

This commit is contained in:
2026-08-15 21:10:14 -04:00
parent 98aa75d185
commit 61f4909bc6
7 changed files with 344 additions and 71 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.2.4"
version = "0.2.5"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+13
View File
@@ -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)."
+134 -48
View File
@@ -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)
}
+177 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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