From ba07f42d0960db900e440ab79f4052b62be8cb0a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 15 Aug 2026 19:59:24 -0400 Subject: [PATCH] v0.2.4 : prompt interactif style Hermes + passerelle shell systeme (shell , detection du shell par defaut) + desinstallation propre (self-uninstall) --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 56 ++- config.yaml | 2 +- src/cli.rs | 6 + src/commands/mod.rs | 2 + src/commands/self_uninstall.rs | 144 +++++++ src/config.rs | 3 +- src/lib.rs | 1 + src/repl.rs | 723 ++++++++++++++++++++++++++++++--- src/shell.rs | 296 ++++++++++++++ 11 files changed, 1177 insertions(+), 60 deletions(-) create mode 100644 src/commands/self_uninstall.rs create mode 100644 src/shell.rs diff --git a/Cargo.lock b/Cargo.lock index e2fab5a..f773e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 50d86e0..5b6592b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.3" +version = "0.2.4" 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/README.md b/README.md index de9cd2a..3fb9015 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,8 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) | am config show / edit / add | gère votre configuration | | am completion | 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,55 @@ 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 # 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 # 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 + +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 (cmd) + 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.) --- diff --git a/config.yaml b/config.yaml index 6fe7c1d..3db0dc5 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/src/cli.rs b/src/cli.rs index 8ed2f78..996222a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -163,6 +163,12 @@ pub enum Command { #[arg(long, value_name = "FILE")] to: Option, }, + /// 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) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 16ccea1..215c1e9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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 { 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), } diff --git a/src/commands/self_uninstall.rs b/src/commands/self_uninstall.rs new file mode 100644 index 0000000..79cfb6b --- /dev/null +++ b/src/commands/self_uninstall.rs @@ -0,0 +1,144 @@ +//! 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, Context, Result}; +use colored::Colorize; +use std::path::{Path, PathBuf}; + +#[cfg(windows)] +const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4; + +#[cfg(windows)] +#[link(name = "Kernel32")] +extern "system" { + fn MoveFileExW( + lpExistingFileName: *const u16, + lpNewFileName: *const u16, + dwFlags: u32, + ) -> i32; +} + +pub fn run(app: &App) -> Result { + // 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)); + } + } + + 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); + } + // 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. + for (_, path) in &roots { + std::fs::remove_dir_all(path) + .with_context(|| format!("cannot remove {}", path.display()))?; + } + for (_, path) in &roots { + println!("{} {}", "removed:".green(), path.display()); + } + } + + // 3. Delete the executable itself. + let exe = std::env::current_exe() + .with_context(|| "cannot locate the agent-manager executable")?; + remove_self(&exe)?; + Ok(0) +} + +/// 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<()> { + #[cfg(unix)] + { + std::fs::remove_file(exe) + .with_context(|| format!("cannot remove {}", exe.display()))?; + println!( + "{} {}", + "agent-manager removed:".green(), + exe.display() + ); + return Ok(()); + } + #[cfg(windows)] + { + 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)", + exe.display() + ); + let spawned = std::process::Command::new("cmd") + .args(["/C", &script]) + .spawn() + .is_ok(); + // Belt and braces: if something keeps the file locked, the standard + // uninstaller mechanism deletes it at the next reboot. + let wide: Vec = exe + .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 !spawned && !at_reboot { + bail!( + "cannot schedule deletion of {} — delete it manually", + exe.display() + ); + } + 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() + ); + } +} diff --git a/src/config.rs b/src/config.rs index 73992b2..ab3b7b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,7 +50,8 @@ pub struct Settings { /// Directory for logs. #[serde(default)] pub log_dir: Option, - /// 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, /// Try to install missing dependencies automatically. diff --git a/src/lib.rs b/src/lib.rs index 1bb7ec7..89624f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/repl.rs b/src/repl.rs index ded1f26..b8a453c 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -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 ' 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, aliases: Vec, @@ -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 { + 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::()) + .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 = 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 ' 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 ' 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 ' 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 { - 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 { +fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { let mut rl: rustyline::Editor = rustyline::Editor::new()?; rl.set_helper(Some(AmCompleter::new(app))); @@ -203,26 +572,20 @@ fn run_with_editor(app: &App) -> Result { 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 { } /// Plain fallback when no terminal/line editor is available. -fn run_plain(app: &App) -> Result { - eprintln!("agent-manager interactive shell — type 'help' for commands, 'exit' to quit"); +fn run_plain(app: &App, session: &mut ShellSession) -> Result { + 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,89 @@ fn run_plain(app: &App) -> Result { 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 "); - println!(" info "); - println!(" install [--method ] [--force]"); - println!(" uninstall [--purge]"); - println!(" update | update --all"); - println!(" start [--background]"); - println!(" stop [--force]"); - println!(" restart [--background] [--force]"); - println!(" run [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 { + 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)?; + Ok(false) } -fn handle_line(app: &App, line: &str) -> Result<()> { +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); + } + return Ok(()); + } let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?; if tokens.is_empty() { return Ok(()); } 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); + } + if cmd == "shell" { + shell_command(session, rest.first().map(|s| s.as_str())); + return Ok(()); + } + // Everything that is not an am command is a system command. + if !is_am_command(cmd) { + return run_system(app, session, line); + } + let need = |what: &str| -> Result { 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,6 +763,26 @@ 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'")); @@ -377,6 +793,121 @@ fn handle_line(app: &App, line: &str) -> Result<()> { Ok(()) } +/// 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 ' 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 search the catalog"); + println!(" info show agent details"); + println!(" install [--method ] install an agent (--force to reinstall)"); + println!(" uninstall [--purge] remove an agent"); + println!(" update | update --all update agents"); + println!(" start [--background] start an agent"); + println!(" stop [--force] stop an agent"); + println!(" restart [--background] restart an agent"); + println!(" run [args...] run an agent command directly"); + println!(" doctor [--fix] check the environment"); + println!(" config show | path | validate | edit"); + println!(" completion 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 restore a backup"); + println!(" shell [list|] 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 ' changes the session directory (in-process)"); + println!(); + println!("slash commands: /help · /version · /exit · /shell "); +} + #[cfg(test)] mod tests { use super::*; @@ -392,6 +923,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 +941,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 +979,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 = 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); + } } diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..98682ee --- /dev/null +++ b/src/shell.rs @@ -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 ` 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, + comspec: Option, +) -> &'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::>() + .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 { + 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}"); + } +}