From 61f4909bc6880c0080eabb62b688cf2f6580648d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 15 Aug 2026 21:10:14 -0400 Subject: [PATCH] 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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- config.yaml | 13 +++ src/commands/info_cmd.rs | 182 +++++++++++++++++++++++--------- src/commands/self_uninstall.rs | 187 +++++++++++++++++++++++++++++++-- src/config.rs | 1 + src/repl.rs | 28 +++-- 7 files changed, 344 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f773e55..5549842 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5b6592b..57627b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/config.yaml b/config.yaml index 3db0dc5..efc88f4 100644 --- a/config.yaml +++ b/config.yaml @@ -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)." diff --git a/src/commands/info_cmd.rs b/src/commands/info_cmd.rs index f2ab9f1..8a5d5c6 100644 --- a/src/commands/info_cmd.rs +++ b/src/commands/info_cmd.rs @@ -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 { 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 = 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 = 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 { ); } + // --- 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) } diff --git a/src/commands/self_uninstall.rs b/src/commands/self_uninstall.rs index 79cfb6b..3cf05eb 100644 --- a/src/commands/self_uninstall.rs +++ b/src/commands/self_uninstall.rs @@ -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 { @@ -67,30 +73,150 @@ pub fn run(app: &App) -> Result { // 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 = 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 = 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) { + 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 = 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 { + use std::os::windows::ffi::OsStrExt; + let mut scheduled = Vec::new(); + for path in locked { + let wide: Vec = 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 { + 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 = 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 = Vec::new(); + remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked); + assert!(locked.is_empty()); + } +} diff --git a/src/config.rs b/src/config.rs index ab3b7b1..2117af4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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)); } diff --git a/src/repl.rs b/src/repl.rs index b8a453c..d9514b7 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -652,37 +652,40 @@ fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result 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 { // '!' 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 { @@ -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