Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d886de322c | ||
|
|
bc38cd59e5 | ||
|
|
61f4909bc6 | ||
|
|
98aa75d185 | ||
|
|
ba07f42d09 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.3"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.3"
|
||||
version = "0.2.7"
|
||||
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
|
||||
@@ -112,7 +112,8 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am config show / edit / add | gère votre configuration |
|
||||
| am completion <shell> | script de complétion (bash, zsh, fish, powershell) |
|
||||
| am self-update | met à jour am lui-même (si configuré) |
|
||||
| am | shell interactif avec Tab et historique |
|
||||
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
|
||||
| am | shell interactif : bannière, passerelle système, Tab et historique |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,10 +167,59 @@ Disponibles avant **ou après** la sous-commande.
|
||||
am start group:dev # démarre tout un groupe en arrière-plan
|
||||
am stop group:dev
|
||||
|
||||
am # shell interactif :
|
||||
am> inst<Tab> # complétion Tab : commandes, agents, groupes, options
|
||||
am> install jcode
|
||||
am> exit # historique persistant (flèches haut/bas)
|
||||
am # shell interactif (bannière style Hermes) :
|
||||
❯ inst<Tab> # complétion Tab : commandes, agents, groupes, options
|
||||
❯ install jcode
|
||||
❯ exit # historique persistant (flèches haut/bas)
|
||||
|
||||
### 🐚 Passerelle système
|
||||
|
||||
Le prompt interactif est aussi une **passerelle vers votre shell
|
||||
système** : toute saisie qui n'est pas une commande am est exécutée par le
|
||||
shell actif. Le **shell par défaut de l'utilisateur est détecté au
|
||||
démarrage et mis en évidence** dans la bannière (config
|
||||
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
|
||||
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
|
||||
|
||||
❯ ls # exécuté par le shell actif (cmd, pwsh, bash, ...)
|
||||
❯ shell # affiche le shell courant + les shells disponibles
|
||||
❯ shell bash # change le shell de la session
|
||||
❯ !list # force l'exécution système (même nom qu'une commande am)
|
||||
❯ cd ~/projets # change le répertoire de la session (persistant)
|
||||
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
|
||||
|
||||
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
|
||||
`config.yaml` (voir `am config path`).
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Désinstallation propre
|
||||
|
||||
Une seule commande, sur tous les OS — elle arrête d'abord les agents en
|
||||
arrière-plan, puis supprime les données, l'état (logs, historique), la
|
||||
configuration, et enfin l'exécutable lui-même :
|
||||
|
||||
am self-uninstall # avec confirmation
|
||||
am self-uninstall --yes # automatique, en une ligne
|
||||
|
||||
Sans le binaire (ou en dernier recours), supprimez les répertoires
|
||||
qu'agent-manager possède :
|
||||
|
||||
# Linux
|
||||
rm -rf ~/.local/share/agent-manager ~/.local/state/agent-manager ~/.config/agent-manager
|
||||
|
||||
# macOS
|
||||
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
|
||||
|
||||
# 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`,
|
||||
supprimez aussi ces emplacements.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-1
@@ -14,7 +14,7 @@ version: "1.0"
|
||||
settings:
|
||||
install_dir: null # default: ~/.local/share/agent-manager/agents
|
||||
log_dir: null # default: ~/.local/state/agent-manager/logs
|
||||
default_shell: null # default: sh on Unix
|
||||
default_shell: null # install scripts + shell gateway (default: user's shell)
|
||||
auto_install_deps: true # offer to install missing dependencies
|
||||
confirm_before_run: true # confirm before running install scripts
|
||||
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
|
||||
@@ -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)."
|
||||
|
||||
+7
-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,
|
||||
@@ -163,6 +163,12 @@ pub enum Command {
|
||||
#[arg(long, value_name = "FILE")]
|
||||
to: Option<PathBuf>,
|
||||
},
|
||||
/// Remove agent-manager and everything it created from this machine
|
||||
///
|
||||
/// Stops every managed background agent, then deletes the data, state and
|
||||
/// configuration directories, and finally the executable itself. Combine
|
||||
/// with --yes for a fully automated one-liner.
|
||||
SelfUninstall,
|
||||
/// Export the configuration and installation state (backup)
|
||||
Export {
|
||||
/// Output file (default: am-export.json)
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod install_cmd;
|
||||
pub mod list_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
@@ -59,6 +60,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
||||
Command::Completion { shell } => completion_cmd::run(*shell),
|
||||
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
|
||||
Command::SelfUninstall => self_uninstall::run(app),
|
||||
Command::Export { output } => export_import::export_cmd(app, output),
|
||||
Command::Import { file } => export_import::import_cmd(app, file),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! self-uninstall: remove agent-manager and everything it created from this
|
||||
//! machine — background agents are stopped first, then the data, state and
|
||||
//! configuration directories are deleted, then the executable itself.
|
||||
|
||||
use crate::app::{self, App};
|
||||
use anyhow::{bail, Result};
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
use colored::Colorize;
|
||||
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")]
|
||||
extern "system" {
|
||||
fn MoveFileExW(
|
||||
lpExistingFileName: *const u16,
|
||||
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> {
|
||||
// 1. Stop every managed background agent.
|
||||
let state = app.state.load()?;
|
||||
for (name, entry) in &state.installed {
|
||||
if let Some(pid) = entry.pid {
|
||||
if crate::process::is_running(pid) {
|
||||
app.log.info(&format!("stopping agent '{name}' (pid {pid})"));
|
||||
crate::process::stop_pid(pid, false, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect the directories agent-manager owns.
|
||||
let data_root = app::data_dir();
|
||||
let state_root = app::state_dir();
|
||||
let mut roots: Vec<(&str, PathBuf)> = Vec::new();
|
||||
if data_root.is_dir() {
|
||||
roots.push(("data", data_root.clone()));
|
||||
}
|
||||
if state_root != data_root && state_root.is_dir() {
|
||||
roots.push(("state", state_root));
|
||||
}
|
||||
if let Some(dir) = crate::config::user_config_dir() {
|
||||
if dir.is_dir() {
|
||||
roots.push(("config", dir));
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok();
|
||||
let mut renamed: Option<PathBuf> = None;
|
||||
|
||||
if roots.is_empty() {
|
||||
app.log
|
||||
.info("nothing to remove — agent-manager left no traces on this machine");
|
||||
} else {
|
||||
app.log.info("agent-manager will remove:");
|
||||
for (label, path) in &roots {
|
||||
println!(" {label}: {}", path.display());
|
||||
}
|
||||
if !app.confirm("Remove everything listed above?")? {
|
||||
app.log.info("cancelled — nothing was removed");
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry("nothing removed (dry run)");
|
||||
return Ok(0);
|
||||
}
|
||||
// Windows allows renaming a running executable (but not deleting
|
||||
// it): do it up-front so the original binary disappears immediately,
|
||||
// even if the deferred deletion has to wait for this process to exit.
|
||||
renamed = exe.as_deref().and_then(rename_for_deletion);
|
||||
if let Some(new) = &renamed {
|
||||
if let Some(old) = &exe {
|
||||
println!(
|
||||
"{} {} -> {}",
|
||||
"renamed:".cyan(),
|
||||
old.display(),
|
||||
new.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
remove_tree(path, &mut locked);
|
||||
}
|
||||
for (_, path) in &roots {
|
||||
if !path.exists() {
|
||||
println!("{} {}", "removed:".green(), path.display());
|
||||
}
|
||||
}
|
||||
if !locked.is_empty() {
|
||||
let scheduled = schedule_locked(&locked);
|
||||
if !scheduled.is_empty() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
"locked:".yellow(),
|
||||
scheduled.len()
|
||||
);
|
||||
}
|
||||
if scheduled.len() != locked.len() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
"deferred:".cyan(),
|
||||
locked.len() - scheduled.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the executable itself, then sweep the directories left
|
||||
// empty behind it.
|
||||
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||
match &exe {
|
||||
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
|
||||
None => app.log
|
||||
.warn("cannot locate the agent-manager executable — delete it manually"),
|
||||
}
|
||||
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 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, renamed: Option<&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(),
|
||||
exe.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
// The file to delete once this process exits: the renamed copy when
|
||||
// the up-front rename worked, the original path otherwise.
|
||||
let target = renamed.unwrap_or(exe);
|
||||
// A temporary batch file retries the deletion for a few seconds (the
|
||||
// image stays locked until we exit), sweeps the directories left
|
||||
// empty, then deletes itself. A .bat avoids every cmd /C command-line
|
||||
// quoting pitfall.
|
||||
let bat = write_cleanup_bat(exe, target, roots);
|
||||
if let Some(b) = &bat {
|
||||
// raw_arg passes the command line verbatim: regular .arg()
|
||||
// backslash-escapes embedded quotes, which cmd.exe does not
|
||||
// understand.
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
let mut cleanup = std::process::Command::new("cmd");
|
||||
cleanup.raw_arg("/C");
|
||||
cleanup.raw_arg(format!("\"{}\"", b.display()));
|
||||
let _ = cleanup.spawn();
|
||||
}
|
||||
// Belt and braces: if something keeps the file locked, the standard
|
||||
// uninstaller mechanism deletes it at the next reboot.
|
||||
let wide: Vec<u16> = target
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let at_reboot = unsafe {
|
||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||
};
|
||||
if bat.is_none() && !at_reboot {
|
||||
bail!(
|
||||
"cannot schedule deletion of {} — delete it manually",
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
if renamed.is_some() {
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
bail!(
|
||||
"automatic removal of the executable is not supported on this platform; delete {} manually",
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the running executable out of the way (Windows allows renaming a
|
||||
/// running image — only deletion is refused while it executes). Returns the
|
||||
/// new path on success.
|
||||
#[cfg(windows)]
|
||||
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
|
||||
let name = exe.file_name()?.to_string_lossy();
|
||||
let new = exe.with_file_name(format!("{name}.deleteme"));
|
||||
std::fs::rename(exe, &new).ok()?;
|
||||
Some(new)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Write a temporary batch file that deletes the target executable once it
|
||||
/// is unlocked, sweeps the directories left empty and then removes itself.
|
||||
#[cfg(windows)]
|
||||
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let crlf = "\r\n";
|
||||
let q = "\"";
|
||||
let mut script = String::new();
|
||||
let mut line = |s: &str| {
|
||||
script.push_str(s);
|
||||
script.push_str(crlf);
|
||||
};
|
||||
line("@echo off");
|
||||
line("set n=0");
|
||||
line(":retry");
|
||||
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
|
||||
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
|
||||
line(&format!(
|
||||
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
|
||||
target.display(),
|
||||
exe.display()
|
||||
));
|
||||
line("set /a n+=1");
|
||||
line("if %n% GEQ 60 goto end");
|
||||
line("ping -n 2 127.0.0.1 >nul");
|
||||
line("goto retry");
|
||||
line(":sweep");
|
||||
if let Some(parent) = exe.parent() {
|
||||
if roots.iter().any(|r| parent.starts_with(r)) {
|
||||
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
|
||||
}
|
||||
}
|
||||
for root in roots {
|
||||
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
|
||||
}
|
||||
line(":end");
|
||||
line(&format!("del {q}%~f0{q} >nul 2>&1"));
|
||||
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
|
||||
std::fs::write(&path, script).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -50,7 +50,8 @@ pub struct Settings {
|
||||
/// Directory for logs.
|
||||
#[serde(default)]
|
||||
pub log_dir: Option<String>,
|
||||
/// Shell used to execute install scripts (defaults to sh on Unix).
|
||||
/// Shell used to execute install scripts and to run system commands
|
||||
/// in the interactive prompt (defaults to the user's default shell).
|
||||
#[serde(default)]
|
||||
pub default_shell: Option<String>,
|
||||
/// Try to install missing dependencies automatically.
|
||||
@@ -623,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod probe;
|
||||
pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod toolchain;
|
||||
pub mod version;
|
||||
|
||||
+680
-53
@@ -1,14 +1,24 @@
|
||||
//! Interactive shell launched when am is run without a subcommand.
|
||||
//!
|
||||
//! The prompt is a Hermes-style gateway between the user and two worlds:
|
||||
//! the am commands (install, start, stop, ...) and the system shell. Every
|
||||
//! input that is not an am command is executed as a system command by the
|
||||
//! selected shell ('shell <name>' switches it), so the prompt doubles as a
|
||||
//! pass-through to pwsh, cmd, bash, zsh, ... The user's default shell is
|
||||
//! detected at startup and highlighted in the banner and the status line.
|
||||
//!
|
||||
//! Uses rustyline for line editing: Tab completion (commands, agents, aliases,
|
||||
//! groups, flags), command history persisted in the state directory, and
|
||||
//! graceful fallback to plain line input when no terminal is available.
|
||||
//! groups, flags, shell names), command history persisted in the state
|
||||
//! directory, and graceful fallback to plain line input when no terminal is
|
||||
//! available.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::{Cli, Command, StartArgs};
|
||||
use crate::commands;
|
||||
use crate::shell::ShellSession;
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::CommandFactory;
|
||||
use colored::Colorize;
|
||||
use rustyline::completion::{Completer, Pair};
|
||||
use rustyline::error::ReadlineError;
|
||||
use rustyline::highlight::Highlighter;
|
||||
@@ -17,6 +27,8 @@ use rustyline::validate::Validator;
|
||||
use rustyline::{Context, Helper};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab completion
|
||||
@@ -27,6 +39,7 @@ pub struct AmCompleter {
|
||||
commands: Vec<&'static str>,
|
||||
config_sub: Vec<&'static str>,
|
||||
shells: Vec<&'static str>,
|
||||
gateway_shells: Vec<&'static str>,
|
||||
options: Vec<&'static str>,
|
||||
agents: Vec<String>,
|
||||
aliases: Vec<String>,
|
||||
@@ -58,10 +71,15 @@ impl AmCompleter {
|
||||
commands: vec![
|
||||
"list", "status", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"help", "version", "exit",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "help",
|
||||
"version", "exit",
|
||||
],
|
||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||
gateway_shells: vec![
|
||||
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
||||
"elvish",
|
||||
],
|
||||
options: vec![
|
||||
"--background", "--force", "--purge", "--method", "--all",
|
||||
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
||||
@@ -97,6 +115,9 @@ impl AmCompleter {
|
||||
"completion" => {
|
||||
pool = self.shells.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"shell" => {
|
||||
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
|
||||
| "info" | "status" => {
|
||||
if word.starts_with("group:") {
|
||||
@@ -172,30 +193,378 @@ impl Highlighter for AmCompleter {}
|
||||
impl Validator for AmCompleter {}
|
||||
impl Helper for AmCompleter {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Banner (Hermes-style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inner width of the banner box; the block art is exactly this wide.
|
||||
pub const BOX_WIDTH: usize = 109;
|
||||
|
||||
/// One 6-row block glyph of the banner art.
|
||||
type Glyph = [&'static str; 6];
|
||||
|
||||
const G_A: Glyph = [
|
||||
" █████╗ ",
|
||||
"██╔══██╗",
|
||||
"███████║",
|
||||
"██╔══██║",
|
||||
"██║ ██║",
|
||||
"╚═╝ ╚═╝",
|
||||
];
|
||||
const G_G: Glyph = [
|
||||
" ██████╗ ",
|
||||
"██╔════╝ ",
|
||||
"██║ ███╗",
|
||||
"██║ ██║",
|
||||
"╚██████╔╝",
|
||||
" ╚═════╝ ",
|
||||
];
|
||||
const G_E: Glyph = [
|
||||
"███████╗",
|
||||
"██╔════╝",
|
||||
"█████╗ ",
|
||||
"██╔══╝ ",
|
||||
"███████╗",
|
||||
"╚══════╝",
|
||||
];
|
||||
const G_N: Glyph = [
|
||||
"███╗ ██╗",
|
||||
"████╗ ██║",
|
||||
"██╔██╗ ██║",
|
||||
"██║╚██╗██║",
|
||||
"██║ ╚████║",
|
||||
"╚═╝ ╚═══╝",
|
||||
];
|
||||
const G_T: Glyph = [
|
||||
"████████╗",
|
||||
"╚══██╔══╝",
|
||||
" ██║ ",
|
||||
" ██║ ",
|
||||
" ██║ ",
|
||||
" ╚═╝ ",
|
||||
];
|
||||
const G_M: Glyph = [
|
||||
"███╗ ███╗",
|
||||
"████╗ ████║",
|
||||
"██╔████╔██║",
|
||||
"██║╚██╔╝██║",
|
||||
"██║ ╚═╝ ██║",
|
||||
"╚═╝ ╚═╝",
|
||||
];
|
||||
const G_R: Glyph = [
|
||||
"██████╗ ",
|
||||
"██╔══██╗",
|
||||
"██████╔╝",
|
||||
"██╔══██╗",
|
||||
"██║ ██║",
|
||||
"╚═╝ ╚═╝",
|
||||
];
|
||||
const G_SP: Glyph = [" ", " ", " ", " ", " ", " "];
|
||||
|
||||
/// "AGENT MANAGER" rendered as 6 rows of block glyphs (BOX_WIDTH wide).
|
||||
pub fn banner_art() -> Vec<String> {
|
||||
let glyphs: &[&Glyph] = &[
|
||||
&G_A, &G_G, &G_E, &G_N, &G_T, &G_SP, &G_M, &G_A, &G_N, &G_A, &G_G, &G_E, &G_R,
|
||||
];
|
||||
(0..6)
|
||||
.map(|row| glyphs.iter().map(|g| g[row]).collect::<String>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boxed banner: block art, Shell Gateway section (default shell
|
||||
/// highlighted), command groups and the stats footer.
|
||||
pub fn banner_box(
|
||||
version: &str,
|
||||
agents: usize,
|
||||
installed: usize,
|
||||
session: &ShellSession,
|
||||
session_id: &str,
|
||||
cwd: &str,
|
||||
color: bool,
|
||||
) -> String {
|
||||
let w = BOX_WIDTH;
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
let inner = |content: String| format!("│ {} │", pad_visible(content, w));
|
||||
let blank = || inner(" ".to_string());
|
||||
|
||||
// Top border with the centered header.
|
||||
let header = format!(" Agent Manager v{version} · shell gateway ");
|
||||
let fill = w + 2 - visible_len(&header);
|
||||
let left = fill / 2;
|
||||
let right = fill - left;
|
||||
rows.push(dim(
|
||||
&format!("╭{}{}{}╮", "─".repeat(left), header, "─".repeat(right)),
|
||||
color,
|
||||
));
|
||||
|
||||
// Block-art title.
|
||||
for art in banner_art() {
|
||||
rows.push(inner(art));
|
||||
}
|
||||
|
||||
// Shell gateway: the user's default shell is highlighted at startup.
|
||||
rows.push(blank());
|
||||
rows.push(inner(bold_cyan("Shell Gateway", color)));
|
||||
let current = format!(
|
||||
"◉ {} — {}{}",
|
||||
green(session.current.name, color),
|
||||
session.current.title,
|
||||
if session.is_default() { " (default)" } else { "" }
|
||||
);
|
||||
rows.push(inner(format!(" {current}")));
|
||||
let others: Vec<&str> = session
|
||||
.available
|
||||
.iter()
|
||||
.filter(|s| s.name != session.current.name)
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
let avail = if others.is_empty() {
|
||||
"(no other shell found on PATH)".to_string()
|
||||
} else {
|
||||
others.join(" · ")
|
||||
};
|
||||
rows.push(inner(dim(&format!(" ⇄ {avail}"), color)));
|
||||
rows.push(inner(dim(
|
||||
" system commands run in the active shell — 'shell <name>' switches",
|
||||
color,
|
||||
)));
|
||||
|
||||
// am command groups.
|
||||
rows.push(blank());
|
||||
rows.push(inner(bold_cyan("Commands", color)));
|
||||
rows.push(inner(" catalog list · status · search · info".to_string()));
|
||||
rows.push(inner(
|
||||
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" system doctor · config · completion · self-update · self-uninstall · export · import"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(" shell shell · help · version · exit".to_string()));
|
||||
|
||||
// Stats footer.
|
||||
rows.push(blank());
|
||||
rows.push(inner(dim(
|
||||
&format!(
|
||||
"am v{version} · {agents} agents · {installed} installed · shell {} · /help for commands",
|
||||
session.current.name
|
||||
),
|
||||
color,
|
||||
)));
|
||||
let prefix = format!(" session {session_id} · cwd ");
|
||||
let budget = w.saturating_sub(visible_len(&prefix) + 2);
|
||||
rows.push(inner(format!("{prefix}{}", shorten_head(cwd, budget))));
|
||||
rows.push(inner(yellow(
|
||||
" ✦ Tip: 'shell <name>' switches the system shell · '!' forces a system command",
|
||||
color,
|
||||
)));
|
||||
rows.push(dim(&format!("╰{}╯", "─".repeat(w + 2)), color));
|
||||
|
||||
rows.join("\n")
|
||||
}
|
||||
|
||||
/// The full startup banner (box + welcome + tip).
|
||||
pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
|
||||
let color = app.color();
|
||||
let mut text = banner_box(
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
agent_count(app),
|
||||
installed_count(app),
|
||||
session,
|
||||
session_id,
|
||||
¤t_dir_string(),
|
||||
color,
|
||||
);
|
||||
text.push('\n');
|
||||
text.push_str(
|
||||
"Welcome to Agent Manager! Type an am command or any system command — /help for commands.\n",
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"{} {}\n",
|
||||
yellow("✦ Tip:", color),
|
||||
dim(
|
||||
&format!(
|
||||
"system commands run in {} — 'shell <name>' switches · '!' forces · 'cd' works in this session",
|
||||
session.current.name
|
||||
),
|
||||
color,
|
||||
),
|
||||
));
|
||||
text
|
||||
}
|
||||
|
||||
/// One-line status shown before every prompt (Hermes-style status bar).
|
||||
fn print_status(app: &App, session: &ShellSession, started: Instant) {
|
||||
let color = app.color();
|
||||
let sep = dim(" │ ", color);
|
||||
let cwd = shorten_head(¤t_dir_string(), 26);
|
||||
let shell_part = format!(
|
||||
"{} {}",
|
||||
bold_cyan(session.current.name, color),
|
||||
dim(&cwd, color)
|
||||
);
|
||||
let counts = dim(
|
||||
&format!(
|
||||
"{} agents · {} installed",
|
||||
agent_count(app),
|
||||
installed_count(app)
|
||||
),
|
||||
color,
|
||||
);
|
||||
let elapsed = dim(&format_elapsed(started.elapsed()), color);
|
||||
println!(
|
||||
"{}am v{}{sep}{shell_part}{sep}{counts}{sep}⏲ {elapsed}",
|
||||
dim(" ⚙ ", color),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
for n in chars.by_ref() {
|
||||
if n == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
strip_ansi(s).chars().count()
|
||||
}
|
||||
|
||||
fn pad_visible(content: String, width: usize) -> String {
|
||||
let n = visible_len(&content);
|
||||
if n >= width {
|
||||
content
|
||||
} else {
|
||||
format!("{content}{}", " ".repeat(width - n))
|
||||
}
|
||||
}
|
||||
|
||||
fn dim(s: &str, color: bool) -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn bold_cyan(s: &str, color: bool) -> String {
|
||||
if color {
|
||||
s.cyan().bold().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn green(s: &str, color: bool) -> String {
|
||||
if color {
|
||||
s.green().bold().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn yellow(s: &str, color: bool) -> String {
|
||||
if color {
|
||||
s.yellow().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the tail of a long path so it fits a fixed budget.
|
||||
fn shorten_head(text: &str, budget: usize) -> String {
|
||||
let n = visible_len(text);
|
||||
if n <= budget {
|
||||
return text.to_string();
|
||||
}
|
||||
let keep = budget.saturating_sub(1).max(1);
|
||||
let mut tail: String = text.chars().rev().take(keep).collect();
|
||||
tail = tail.chars().rev().collect();
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
fn format_elapsed(d: Duration) -> String {
|
||||
let s = d.as_secs();
|
||||
if s < 60 {
|
||||
format!("{s}s")
|
||||
} else if s < 3600 {
|
||||
format!("{}m{:02}s", s / 60, s % 60)
|
||||
} else {
|
||||
format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique session stamp, e.g. 20260815_184649_a1b2c3.
|
||||
pub fn session_id() -> String {
|
||||
let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("{stamp}_{:06x}", nanos & 0x00ff_ffff)
|
||||
}
|
||||
|
||||
fn agent_count(app: &App) -> usize {
|
||||
app.catalog.agents().iter().filter(|a| !a.hidden).count()
|
||||
}
|
||||
|
||||
fn installed_count(app: &App) -> usize {
|
||||
app.state
|
||||
.load()
|
||||
.ok()
|
||||
.map(|s| s.installed.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn current_dir_string() -> String {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| "?".to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
// AM_FORCE_REPL lets scripts and tests exercise the REPL without a TTY.
|
||||
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
|
||||
if !std::io::stdin().is_terminal() && !forced {
|
||||
// Piped stdin: print the help instead of hanging.
|
||||
let mut cmd = Cli::command();
|
||||
cmd.print_help()?;
|
||||
println!();
|
||||
return Ok(2);
|
||||
}
|
||||
match run_with_editor(app) {
|
||||
let mut session = ShellSession::start(&app.config);
|
||||
let sid = session_id();
|
||||
print!("{}", banner(app, &session, &sid));
|
||||
match run_with_editor(app, &mut session) {
|
||||
Ok(code) => Ok(code),
|
||||
Err(e) => {
|
||||
app.log.verbose(&format!(
|
||||
"line editor unavailable ({e:#}); falling back to plain input"
|
||||
));
|
||||
run_plain(app)
|
||||
run_plain(app, &mut session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_with_editor(app: &App) -> Result<i32> {
|
||||
fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
|
||||
rustyline::Editor::new()?;
|
||||
rl.set_helper(Some(AmCompleter::new(app)));
|
||||
@@ -203,26 +572,20 @@ fn run_with_editor(app: &App) -> Result<i32> {
|
||||
if rl.load_history(&history_path).is_err() {
|
||||
// First run: no history yet.
|
||||
}
|
||||
eprintln!(
|
||||
"agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit'"
|
||||
);
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match rl.readline("am> ") {
|
||||
print_status(app, session, started);
|
||||
match rl.readline("❯ ") {
|
||||
Ok(line) => {
|
||||
let trimmed = line.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let _ = rl.add_history_entry(trimmed.as_str());
|
||||
match trimmed.as_str() {
|
||||
"exit" | "quit" | "q" => break,
|
||||
"help" | "?" => print_help(),
|
||||
"version" => println!("am {}", env!("CARGO_PKG_VERSION")),
|
||||
_ => {
|
||||
if let Err(e) = handle_line(app, &trimmed) {
|
||||
app.log.error(&format!("{e:#}"));
|
||||
}
|
||||
}
|
||||
match handle_input(app, session, &trimmed) {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => continue,
|
||||
@@ -238,10 +601,11 @@ fn run_with_editor(app: &App) -> Result<i32> {
|
||||
}
|
||||
|
||||
/// Plain fallback when no terminal/line editor is available.
|
||||
fn run_plain(app: &App) -> Result<i32> {
|
||||
eprintln!("agent-manager interactive shell — type 'help' for commands, 'exit' to quit");
|
||||
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
eprint!("am> ");
|
||||
print_status(app, session, started);
|
||||
eprint!("❯ ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line).is_err() {
|
||||
@@ -251,57 +615,92 @@ fn run_plain(app: &App) -> Result<i32> {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match line.as_str() {
|
||||
"exit" | "quit" | "q" => break,
|
||||
"help" | "?" => print_help(),
|
||||
"version" => println!("am {}", env!("CARGO_PKG_VERSION")),
|
||||
_ => {
|
||||
if let Err(e) = handle_line(app, &line) {
|
||||
app.log.error(&format!("{e:#}"));
|
||||
}
|
||||
}
|
||||
match handle_input(app, session, &line) {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("commands:");
|
||||
println!(" list [--all]");
|
||||
println!(" status [agent]");
|
||||
println!(" search <keyword>");
|
||||
println!(" info <agent>");
|
||||
println!(" install <agent> [--method <n|type>] [--force]");
|
||||
println!(" uninstall <agent> [--purge]");
|
||||
println!(" update <agent> | update --all");
|
||||
println!(" start <agent> [--background]");
|
||||
println!(" stop <agent> [--force]");
|
||||
println!(" restart <agent> [--background] [--force]");
|
||||
println!(" run <agent> [args...]");
|
||||
println!(" doctor [--fix]");
|
||||
println!(" config show | path | validate | edit");
|
||||
println!(" help | version | exit");
|
||||
/// Handle one line of input. Returns true when the session should exit.
|
||||
fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<bool> {
|
||||
match line {
|
||||
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
||||
"help" | "?" | "/help" => {
|
||||
print_help();
|
||||
return Ok(false);
|
||||
}
|
||||
"version" | "/version" => {
|
||||
println!("am {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Slash commands (Hermes-style).
|
||||
if let Some(rest) = line.strip_prefix('/') {
|
||||
let (cmd, arg) = match rest.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, Some(a.trim())),
|
||||
None => (rest, None),
|
||||
};
|
||||
match cmd {
|
||||
"shell" => shell_command(session, arg),
|
||||
_ => app
|
||||
.log
|
||||
.error(&format!("unknown command '/{cmd}' — type '/help'")),
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
handle_line(app, session, line)
|
||||
}
|
||||
|
||||
fn handle_line(app: &App, 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() {
|
||||
run_system(app, session, rest)?;
|
||||
}
|
||||
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") {
|
||||
change_dir(rest)?;
|
||||
return Ok(false);
|
||||
}
|
||||
if cmd == "shell" {
|
||||
shell_command(session, rest.first().map(|s| s.as_str()));
|
||||
return Ok(false);
|
||||
}
|
||||
// Everything that is not an am command is a system command.
|
||||
if !is_am_command(cmd) {
|
||||
run_system(app, session, line)?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let need = |what: &str| -> Result<String> {
|
||||
rest.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing {what}"))
|
||||
};
|
||||
let flag = |f: &str| rest.iter().any(|t| t == f);
|
||||
let method_flag = || {
|
||||
let opt_value = |f: &str| {
|
||||
rest.iter()
|
||||
.position(|t| t == "--method")
|
||||
.position(|t| t == f)
|
||||
.and_then(|i| rest.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let method_flag = || opt_value("--method");
|
||||
let command = match cmd {
|
||||
"list" => Command::List {
|
||||
all: flag("--all"),
|
||||
@@ -367,16 +766,154 @@ fn handle_line(app: &App, line: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
"config" => Command::Config(crate::cli::ConfigCmd::Show),
|
||||
"completion" => {
|
||||
let name = need("shell name")?;
|
||||
let shell: clap_complete::Shell = name
|
||||
.parse()
|
||||
.map_err(|e: String| anyhow!("unknown shell '{name}': {e}"))?;
|
||||
Command::Completion { shell }
|
||||
}
|
||||
"self-update" => Command::SelfUpdate {
|
||||
check: flag("--check"),
|
||||
to: opt_value("--to").map(PathBuf::from),
|
||||
},
|
||||
"self-uninstall" => Command::SelfUninstall,
|
||||
"export" => Command::Export {
|
||||
output: opt_value("--output")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("am-export.json")),
|
||||
},
|
||||
"import" => Command::Import {
|
||||
file: need("file")?.into(),
|
||||
},
|
||||
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(exit_after)
|
||||
}
|
||||
|
||||
/// Whether a word is one of the am commands (everything else is executed by
|
||||
/// the system shell).
|
||||
fn is_am_command(word: &str) -> bool {
|
||||
matches!(
|
||||
word,
|
||||
"list"
|
||||
| "status"
|
||||
| "search"
|
||||
| "info"
|
||||
| "install"
|
||||
| "uninstall"
|
||||
| "update"
|
||||
| "start"
|
||||
| "stop"
|
||||
| "restart"
|
||||
| "run"
|
||||
| "doctor"
|
||||
| "config"
|
||||
| "completion"
|
||||
| "self-update"
|
||||
| "self-uninstall"
|
||||
| "export"
|
||||
| "import"
|
||||
)
|
||||
}
|
||||
|
||||
/// Run one system command through the active shell (inherited stdio).
|
||||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||||
let code = session.run(line)?;
|
||||
if code != 0 {
|
||||
app.log
|
||||
.error(&format!("command exited with code {code}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change the session directory (in-process, unlike shell commands).
|
||||
fn change_dir(args: &[String]) -> Result<()> {
|
||||
let target: PathBuf = match args.first().map(|s| s.as_str()) {
|
||||
None | Some("~") => crate::config::home_dir()
|
||||
.ok_or_else(|| anyhow!("cannot find the home directory"))?,
|
||||
Some(p) => crate::config::expand_path(p),
|
||||
};
|
||||
std::env::set_current_dir(&target)
|
||||
.map_err(|e| anyhow!("cd {}: {e}", target.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show or switch the system shell used by the gateway.
|
||||
fn shell_command(session: &mut ShellSession, arg: Option<&str>) {
|
||||
match arg {
|
||||
None | Some("list") => print_shell_status(session),
|
||||
Some(name) => match session.switch(name) {
|
||||
Ok(spec) => println!(
|
||||
"{}",
|
||||
format!("shell: {} ({})", spec.name, spec.title).green().bold()
|
||||
),
|
||||
Err(e) => eprintln!("{} {}", "error:".red().bold(), e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn print_shell_status(session: &ShellSession) {
|
||||
let others: Vec<&str> = session
|
||||
.available
|
||||
.iter()
|
||||
.filter(|s| s.name != session.current.name)
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
println!(
|
||||
"current shell: {} — {}{}",
|
||||
session.current.name.bold(),
|
||||
session.current.title,
|
||||
if session.is_default() { " (default)" } else { "" }
|
||||
);
|
||||
if others.is_empty() {
|
||||
println!("no other shell found on PATH");
|
||||
} else {
|
||||
println!("available: {}", others.join(", "));
|
||||
}
|
||||
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("commands:");
|
||||
println!(" list [--all] list installed agents (--all: whole catalog)");
|
||||
println!(" status [agent] show agent state");
|
||||
println!(" search <keyword> search the catalog");
|
||||
println!(" info <agent> show agent details");
|
||||
println!(" install <agent> [--method <m>] install an agent (--force to reinstall)");
|
||||
println!(" uninstall <agent> [--purge] remove an agent");
|
||||
println!(" update <agent> | update --all update agents");
|
||||
println!(" start <agent> [--background] start an agent");
|
||||
println!(" stop <agent> [--force] stop an agent");
|
||||
println!(" restart <agent> [--background] restart an agent");
|
||||
println!(" run <agent> [args...] run an agent command directly");
|
||||
println!(" doctor [--fix] check the environment");
|
||||
println!(" config show | path | validate | edit");
|
||||
println!(" completion <shell> generate a completion script");
|
||||
println!(" self-update [--check] update agent-manager");
|
||||
println!(" self-uninstall remove agent-manager and everything it created");
|
||||
println!(" export [--output FILE] backup config + state");
|
||||
println!(" import <file> restore a backup");
|
||||
println!(" shell [list|<name>] show or switch the system shell");
|
||||
println!(" help | version | exit this help, the version, quit");
|
||||
println!();
|
||||
println!("system gateway:");
|
||||
println!(" any other input runs as a system command in the active shell");
|
||||
println!(" prefix with '!' to force system execution (e.g. '!list')");
|
||||
println!(" 'cd <dir>' changes the session directory (in-process)");
|
||||
println!();
|
||||
println!("slash commands: /help · /version · /exit · /shell <name>");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -392,6 +929,17 @@ mod tests {
|
||||
AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups)
|
||||
}
|
||||
|
||||
fn test_session() -> ShellSession {
|
||||
ShellSession {
|
||||
current: crate::shell::find("pwsh").unwrap(),
|
||||
default: crate::shell::find("pwsh").unwrap(),
|
||||
available: vec![
|
||||
crate::shell::find("pwsh").unwrap(),
|
||||
crate::shell::find("bash").unwrap(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_commands() {
|
||||
let c = completer();
|
||||
@@ -399,6 +947,10 @@ mod tests {
|
||||
assert_eq!(start, 0);
|
||||
assert!(cands.contains(&"start".to_string()));
|
||||
assert!(cands.contains(&"status".to_string()));
|
||||
let (_, cands) = c.candidates_for("sh", 2);
|
||||
assert!(cands.contains(&"shell".to_string()));
|
||||
let (_, cands) = c.candidates_for("self", 4);
|
||||
assert!(cands.contains(&"self-update".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -433,4 +985,79 @@ mod tests {
|
||||
let (_, cands) = c.candidates_for("config sh", 9);
|
||||
assert!(cands.contains(&"show".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_shell_names() {
|
||||
let c = completer();
|
||||
let (_, cands) = c.candidates_for("shell pw", 8);
|
||||
assert!(cands.contains(&"pwsh".to_string()));
|
||||
let (_, cands) = c.candidates_for("shell po", 8);
|
||||
assert!(cands.contains(&"powershell".to_string()));
|
||||
let (_, cands) = c.candidates_for("shell ", 6);
|
||||
assert!(cands.contains(&"list".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_art_is_rectangular() {
|
||||
let rows = banner_art();
|
||||
assert_eq!(rows.len(), 6);
|
||||
for row in &rows {
|
||||
assert_eq!(row.chars().count(), BOX_WIDTH, "row width mismatch: {row:?}");
|
||||
}
|
||||
assert!(rows[0].contains("█████"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_box_lines_are_aligned() {
|
||||
let session = test_session();
|
||||
let text = banner_box(
|
||||
"9.9.9",
|
||||
65,
|
||||
3,
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
false,
|
||||
);
|
||||
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
|
||||
assert!(text.contains("Shell Gateway"));
|
||||
assert!(text.contains("pwsh"));
|
||||
assert!(text.contains("PowerShell 7"));
|
||||
assert!(text.contains("(default)"));
|
||||
assert!(text.contains("bash"));
|
||||
assert!(text.contains("/help for commands"));
|
||||
assert!(text.contains("agent-manager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_am_command_recognizes_known_commands() {
|
||||
for cmd in [
|
||||
"list", "status", "search", "info", "install", "uninstall", "update", "start",
|
||||
"stop", "restart", "run", "doctor", "config", "completion", "self-update",
|
||||
"self-uninstall", "export", "import",
|
||||
] {
|
||||
assert!(is_am_command(cmd), "{cmd}");
|
||||
}
|
||||
for cmd in ["ls", "dir", "echo", "Get-ChildItem", "git", "shell", "help", "cd"] {
|
||||
assert!(!is_am_command(cmd), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_renders_units() {
|
||||
assert_eq!(format_elapsed(Duration::from_secs(0)), "0s");
|
||||
assert_eq!(format_elapsed(Duration::from_secs(59)), "59s");
|
||||
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m05s");
|
||||
assert_eq!(format_elapsed(Duration::from_secs(3725)), "1h02m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_head_keeps_the_tail() {
|
||||
assert_eq!(shorten_head("abc", 10), "abc");
|
||||
let s = shorten_head("/very/long/path/here", 8);
|
||||
assert!(s.starts_with('…'));
|
||||
assert!(s.ends_with("here"));
|
||||
assert!(s.chars().count() <= 9);
|
||||
}
|
||||
}
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
//! System-shell gateway.
|
||||
//!
|
||||
//! The interactive prompt doubles as a gateway between the user and their
|
||||
//! system shell: every input that is not an am command is executed by the
|
||||
//! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...).
|
||||
//!
|
||||
//! The default shell is resolved at startup from, in order: the
|
||||
//! `settings.default_shell` configuration entry, the `SHELL` environment
|
||||
//! variable, `COMSPEC` (Windows), then the first shell found on PATH among
|
||||
//! the platform's preferred shells. `shell <name>` switches at runtime.
|
||||
|
||||
use crate::config::Config;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
/// One supported system shell and how to invoke it with a command line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShellSpec {
|
||||
/// Canonical short name ("pwsh", "cmd", "bash", ...).
|
||||
pub name: &'static str,
|
||||
/// Human-readable title ("PowerShell 7", "Command Prompt", ...).
|
||||
pub title: &'static str,
|
||||
/// Executable to spawn.
|
||||
pub program: &'static str,
|
||||
/// Arguments placed before the user's command line.
|
||||
pub args: &'static [&'static str],
|
||||
/// Executable probed on PATH to decide whether the shell is available.
|
||||
pub probe: &'static str,
|
||||
}
|
||||
|
||||
/// Every shell the gateway understands, in display order.
|
||||
pub static KNOWN_SHELLS: &[ShellSpec] = &[
|
||||
ShellSpec {
|
||||
name: "pwsh",
|
||||
title: "PowerShell 7",
|
||||
program: "pwsh",
|
||||
args: &["-NoLogo", "-NoProfile", "-Command"],
|
||||
probe: "pwsh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "powershell",
|
||||
title: "Windows PowerShell",
|
||||
program: "powershell",
|
||||
args: &["-NoLogo", "-NoProfile", "-Command"],
|
||||
probe: "powershell",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "cmd",
|
||||
title: "Command Prompt",
|
||||
program: "cmd",
|
||||
args: &["/C"],
|
||||
probe: "cmd",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "bash",
|
||||
title: "Bash",
|
||||
program: "bash",
|
||||
args: &["-c"],
|
||||
probe: "bash",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "zsh",
|
||||
title: "Zsh",
|
||||
program: "zsh",
|
||||
args: &["-c"],
|
||||
probe: "zsh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "fish",
|
||||
title: "Fish",
|
||||
program: "fish",
|
||||
args: &["-c"],
|
||||
probe: "fish",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "sh",
|
||||
title: "POSIX sh",
|
||||
program: "sh",
|
||||
args: &["-c"],
|
||||
probe: "sh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "nu",
|
||||
title: "Nushell",
|
||||
program: "nu",
|
||||
args: &["-c"],
|
||||
probe: "nu",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "elvish",
|
||||
title: "Elvish",
|
||||
program: "elvish",
|
||||
args: &["-c"],
|
||||
probe: "elvish",
|
||||
},
|
||||
];
|
||||
|
||||
/// Resolve a shell by canonical name or common alias, case-insensitively
|
||||
/// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...).
|
||||
pub fn find(name: &str) -> Option<&'static ShellSpec> {
|
||||
let mut n = name.trim().to_ascii_lowercase();
|
||||
if let Some(stripped) = n.strip_suffix(".exe") {
|
||||
n = stripped.to_string();
|
||||
}
|
||||
KNOWN_SHELLS.iter().find(|s| {
|
||||
s.name == n
|
||||
|| match s.name {
|
||||
"pwsh" => n == "powershell7" || n == "ps7",
|
||||
"powershell" => n == "ps",
|
||||
"cmd" => n == "dos" || n == "command" || n == "command-prompt",
|
||||
"sh" => n == "posix" || n == "dash",
|
||||
"nu" => n == "nushell",
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a shell executable can be found (or, for cmd on Windows, whether
|
||||
/// COMSPEC is set).
|
||||
pub fn probe_available(spec: &ShellSpec) -> bool {
|
||||
which::which(spec.probe).is_ok()
|
||||
|| (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some())
|
||||
}
|
||||
|
||||
/// Resolve the user's default shell from the configuration, environment
|
||||
/// variables, then the shells found on PATH. Always returns a shell.
|
||||
pub fn resolve_default(
|
||||
configured: Option<&str>,
|
||||
shell_env: Option<OsString>,
|
||||
comspec: Option<OsString>,
|
||||
) -> &'static ShellSpec {
|
||||
if let Some(s) = configured.and_then(find) {
|
||||
return s;
|
||||
}
|
||||
if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) {
|
||||
return s;
|
||||
}
|
||||
if let Some(s) = comspec.and_then(|v| from_env_path(&v)) {
|
||||
return s;
|
||||
}
|
||||
let preferred: &[&str] = if cfg!(windows) {
|
||||
&["pwsh", "powershell", "cmd"]
|
||||
} else {
|
||||
&["bash", "zsh", "fish", "sh"]
|
||||
};
|
||||
for name in preferred {
|
||||
if let Some(s) = find(name) {
|
||||
if probe_available(s) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Last resort: the platform's built-in shell.
|
||||
find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist")
|
||||
}
|
||||
|
||||
/// Extract a shell name from an environment value such as SHELL or COMSPEC.
|
||||
fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> {
|
||||
let text = value.to_string_lossy();
|
||||
let name = Path::new(text.as_ref())
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
find(&name)
|
||||
}
|
||||
|
||||
/// The gateway's runtime state: which shell executes system commands now,
|
||||
/// which one was detected as the user's default, and which are installed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellSession {
|
||||
/// Shell executing system commands right now.
|
||||
pub current: &'static ShellSpec,
|
||||
/// The shell detected as the user's default at startup.
|
||||
pub default: &'static ShellSpec,
|
||||
/// Shells found on this machine.
|
||||
pub available: Vec<&'static ShellSpec>,
|
||||
}
|
||||
|
||||
impl ShellSession {
|
||||
/// Build a session: detect the default shell and probe availability.
|
||||
pub fn start(config: &Config) -> Self {
|
||||
let default = resolve_default(
|
||||
config.settings.default_shell.as_deref(),
|
||||
std::env::var_os("SHELL"),
|
||||
std::env::var_os("COMSPEC"),
|
||||
);
|
||||
let available = KNOWN_SHELLS
|
||||
.iter()
|
||||
.filter(|s| probe_available(s))
|
||||
.collect();
|
||||
Self {
|
||||
current: default,
|
||||
default,
|
||||
available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active shell. Fails for unknown or unavailable shells.
|
||||
pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> {
|
||||
let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?;
|
||||
if !self.available.iter().any(|s| s.name == spec.name) {
|
||||
return Err(anyhow!(
|
||||
"shell '{}' is not available on this machine (available: {})",
|
||||
spec.name,
|
||||
self.available
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
self.current = spec;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// Whether the active shell is the user's default shell.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.current.name == self.default.name
|
||||
}
|
||||
|
||||
/// Execute one system command line in the current shell with inherited
|
||||
/// stdio, so interactive programs keep working. Returns the exit code.
|
||||
pub fn run(&self, line: &str) -> Result<i32> {
|
||||
let status = ProcessCommand::new(self.current.program)
|
||||
.args(self.current.args)
|
||||
.arg(line)
|
||||
.status()
|
||||
.map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_resolves_aliases() {
|
||||
assert_eq!(find("pwsh").unwrap().name, "pwsh");
|
||||
assert_eq!(find("PowerShell7").unwrap().name, "pwsh");
|
||||
assert_eq!(find("ps").unwrap().name, "powershell");
|
||||
assert_eq!(find("cmd.exe").unwrap().name, "cmd");
|
||||
assert_eq!(find("CMD").unwrap().name, "cmd");
|
||||
assert_eq!(find("nushell").unwrap().name, "nu");
|
||||
assert_eq!(find("dash").unwrap().name, "sh");
|
||||
assert!(find("blorp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_default_prefers_configured_shell() {
|
||||
let s = resolve_default(Some("zsh"), None, None);
|
||||
assert_eq!(s.name, "zsh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_default_uses_environment_values() {
|
||||
// SHELL wins over COMSPEC when the configured value is absent.
|
||||
let s = resolve_default(
|
||||
None,
|
||||
Some(OsString::from("/usr/bin/bash")),
|
||||
Some(OsString::from("C:/Windows/system32/cmd.exe")),
|
||||
);
|
||||
assert_eq!(s.name, "bash");
|
||||
// COMSPEC alone is honored.
|
||||
let s = resolve_default(
|
||||
None,
|
||||
None,
|
||||
Some(OsString::from("C:/Windows/system32/cmd.exe")),
|
||||
);
|
||||
assert_eq!(s.name, "cmd");
|
||||
// Unrecognized values fall through to a probe — result is still a
|
||||
// known shell.
|
||||
let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None);
|
||||
assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_validates_availability() {
|
||||
let sh = find("sh").unwrap();
|
||||
let bash = find("bash").unwrap();
|
||||
let mut session = ShellSession {
|
||||
current: sh,
|
||||
default: sh,
|
||||
available: vec![sh, bash],
|
||||
};
|
||||
assert_eq!(session.switch("bash").unwrap().name, "bash");
|
||||
assert_eq!(session.current.name, "bash");
|
||||
assert!(!session.is_default());
|
||||
let err = session.switch("zsh").unwrap_err();
|
||||
assert!(err.to_string().contains("not available"), "{err}");
|
||||
let err = session.switch("blorp").unwrap_err();
|
||||
assert!(err.to_string().contains("unknown shell"), "{err}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user