v0.2.4 : prompt interactif style Hermes + passerelle shell systeme (shell <nom>, detection du shell par defaut) + desinstallation propre (self-uninstall)

This commit is contained in:
2026-08-15 19:59:24 -04:00
parent 2be5f8e8b2
commit ba07f42d09
11 changed files with 1177 additions and 60 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.3"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+51 -5
View File
@@ -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,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<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 (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.)
---
+1 -1
View File
@@ -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
+6
View File
@@ -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)
+2
View File
@@ -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),
}
+144
View File
@@ -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<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));
}
}
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<u16> = 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()
);
}
}
+2 -1
View File
@@ -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.
+1
View File
@@ -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;
+672 -51
View File
@@ -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,
&current_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(&current_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,89 @@ 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)?;
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<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,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 <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 +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<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
View File
@@ -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}");
}
}