Files
agent-manager/src/repl.rs
T

1320 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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::nav;
use crate::ps;
use crate::tables::DataTable;
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;
use rustyline::hint::Hinter;
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
// ---------------------------------------------------------------------------
/// Completion candidates for the interactive shell.
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>,
groups: Vec<String>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
}
/// One-line descriptions for the Tab completion menu.
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("list", "list installed agents"),
("status", "show agent state"),
("search", "search the catalog"),
("info", "agent details"),
("install", "install an agent"),
("uninstall", "remove an agent"),
("update", "update agents"),
("start", "start an agent"),
("stop", "stop an agent"),
("restart", "restart an agent"),
("run", "run an agent command"),
("doctor", "check the environment"),
("config", "manage the configuration"),
("completion", "generate a completion script"),
("self-update", "update agent-manager"),
("self-uninstall", "remove agent-manager"),
("export", "backup config + state"),
("import", "restore a backup"),
("shell", "show or switch the system shell"),
("ls", "list files and directories (Nushell-style)"),
("dir", "list files and directories (same as ls)"),
("cd", "change directory — Tab completes folders"),
("ps", "list processes"),
("where", "filter the last table — where size > 1mb"),
("get", "select a column — get name"),
("help", "show this help"),
("version", "print the version"),
("exit", "quit the shell"),
];
impl AmCompleter {
pub fn new(app: &App) -> Self {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
}
pub fn from_catalog(
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
groups: &BTreeMap<String, Vec<String>>,
) -> Self {
let mut agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.map(|a| a.name.clone())
.collect();
agents.sort();
let mut alias_list: Vec<String> = aliases.keys().cloned().collect();
alias_list.sort();
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
group_list.sort();
let mut descriptions: BTreeMap<String, String> = BTreeMap::new();
for (cmd, desc) in COMMAND_DESCRIPTIONS {
descriptions.insert((*cmd).to_string(), (*desc).to_string());
}
for a in catalog.agents() {
if a.hidden {
continue;
}
let desc = a
.category
.clone()
.or_else(|| a.description.clone())
.unwrap_or_default();
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self {
commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "ls", "dir",
"cd", "ps", "where", "get", "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",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
],
agents,
aliases: alias_list,
groups: group_list,
descriptions,
}
}
/// Pure completion logic: given the line up to the cursor, returns the
/// replacement start position and the matching candidate strings.
pub fn candidates_for(&self, line: &str, pos: usize) -> (usize, Vec<String>) {
let before = &line[..pos.min(line.len())];
let start = before
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
let word = &before[start..];
let previous: Vec<&str> = before[..start].split_whitespace().collect();
let mut pool: Vec<String> = Vec::new();
if previous.is_empty() {
// Completing the command name itself.
pool = self.commands.iter().map(|s| s.to_string()).collect();
} else {
let first = previous[0];
match first {
"config" => {
pool = self.config_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
"shell" => {
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
}
"cd" | "chdir" => {
// Complete directories only — dive one level per Tab.
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, true);
}
"ls" | "dir" => {
// Complete paths (files and directories).
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, false);
}
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "info" | "status" => {
if word.starts_with("group:") {
pool = self.groups.clone();
} else if previous.len() == 1 {
pool.extend(self.agents.iter().cloned());
pool.extend(self.aliases.iter().cloned());
pool.extend(self.groups.iter().cloned());
} else if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else {
// Extra arguments: only offer flags.
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
_ => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else {
// Nushell fallback: complete file paths for external
// (system) commands when nothing else applies.
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, false);
}
}
}
}
let mut cands: Vec<String> = pool
.iter()
.filter(|c| c.starts_with(word))
.cloned()
.collect();
if cands.is_empty() && !word.is_empty() {
let lower = word.to_lowercase();
cands = pool
.into_iter()
.filter(|c| c.to_lowercase().starts_with(&lower))
.collect();
} else if word.is_empty() {
cands = pool;
}
cands.sort();
cands.dedup();
(start, cands)
}
}
impl Completer for AmCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = cands
.into_iter()
.map(|c| {
// Nushell-style menu: show a description next to the
// candidate when one is known.
let display = match self.descriptions.get(&c) {
Some(d) if !d.is_empty() => format!("{c}\t{d}"),
_ => c.clone(),
};
Pair {
display,
replacement: c,
}
})
.collect();
Ok((start, pairs))
}
}
impl Hinter for AmCompleter {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
None
}
}
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,
external: 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 ls · cd · ps · where · get · shell · help · version · exit".to_string(),
));
// Stats footer.
rows.push(blank());
rows.push(inner(dim(
&format!(
"am v{version} · {agents} agents · {external} external · {installed} managed · 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),
external_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 · {} external · {} managed",
agent_count(app),
external_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)
}
/// Number of external agents (installed outside agent-manager) detected on
/// the PATH — the same definition used by the "external" status in am list.
fn external_count(app: &App) -> usize {
let probe = crate::probe::detect_externals(app);
let managed: std::collections::BTreeSet<String> = app
.state
.load()
.ok()
.map(|s| s.installed.keys().cloned().collect())
.unwrap_or_default();
app.catalog
.agents()
.iter()
.filter(|a| !a.hidden && a.installable)
.filter(|a| !managed.contains(&a.name))
.filter(|a| probe.path_of(&a.name).is_some())
.count()
}
fn current_dir_string() -> String {
std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "?".to_string())
}
fn current_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Keep a catalog description short enough for the completion menu.
fn truncate_desc(desc: &str) -> String {
let one_line = desc.lines().next().unwrap_or(desc);
let mut out: String = one_line.chars().take(64).collect();
if one_line.chars().count() > 64 {
out.push('…');
}
out
}
// ---------------------------------------------------------------------------
// Interactive loop
// ---------------------------------------------------------------------------
pub fn run(app: &App) -> Result<i32> {
// 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);
}
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, &mut session)
}
}
}
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)));
// Nushell-style completion: a single match is inserted immediately;
// otherwise Tab inserts the longest common prefix and the menu lists the
// candidates with their descriptions.
use rustyline::config::{CompletionType, Configurer};
let _ = rl.set_completion_type(CompletionType::List);
let history_path = app.paths.state_file.with_file_name("history.txt");
if rl.load_history(&history_path).is_err() {
// First run: no history yet.
}
let started = Instant::now();
let mut last: Option<DataTable> = None;
loop {
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 handle_input(app, session, &mut last, &trimmed) {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
}
}
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => break,
Err(e) => {
app.log.warn(&format!("input error: {e}"));
break;
}
}
}
let _ = rl.save_history(&history_path);
Ok(0)
}
/// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
let started = Instant::now();
let mut last: Option<DataTable> = None;
loop {
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() {
break;
}
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
match handle_input(app, session, &mut last, &line) {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
}
}
Ok(0)
}
/// Handle one line of input. Returns true when the session should exit.
fn handle_input(
app: &App,
session: &mut ShellSession,
last: &mut Option<DataTable>,
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, last, line)
}
/// 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,
last: &mut Option<DataTable>,
line: &str,
) -> Result<bool> {
// Nushell-style pipelines: ls | where ... | get ...
if line.contains('|') {
return eval_pipeline(app, last, line);
}
// '!' 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(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);
}
// Nushell-style directory listing (the system ls stays available as !ls,
// and dir maps to the same table on Windows).
if cmd == "ls" || (cmd == "dir" && cfg!(windows)) {
let table = nav::ls_table(&ls_target(rest)?, app.color())?;
show_table(app, last, table);
return Ok(false);
}
if cmd == "ps" {
show_table(app, last, ps::ps_table());
return Ok(false);
}
if cmd == "where" {
let base = last
.clone()
.ok_or_else(|| anyhow!("'where' needs a table — run ls or ps first (e.g. ls | where size > 1mb)"))?;
if rest.len() < 3 {
anyhow::bail!("usage: where <column> <op> <value>");
}
let filtered =
crate::tables::filter_where(&base, &rest[0], &rest[1], &rest[2..].join(" "))?;
show_table(app, last, filtered);
return Ok(false);
}
if cmd == "get" {
let base = last
.clone()
.ok_or_else(|| anyhow!("'get' needs a table — run ls or ps first (e.g. ls | get name)"))?;
let field = rest
.first()
.ok_or_else(|| anyhow!("usage: get <column>"))?;
let column = crate::tables::get_column(&base, field)?;
show_table(app, last, column);
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 opt_value = |f: &str| {
rest.iter()
.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"),
category: None,
},
"status" => Command::Status {
agent: rest.first().cloned(),
},
"search" => Command::Search {
keyword: rest.join(" "),
category: None,
},
"info" => Command::Info {
agent: need("agent name")?,
},
"start" => Command::Start(StartArgs {
agent: need("agent name")?,
background: flag("--background") || flag("-b"),
..Default::default()
}),
"stop" => Command::Stop {
agent: need("agent name")?,
force: flag("--force"),
timeout: None,
},
"restart" => Command::Restart {
start: StartArgs {
agent: need("agent name")?,
background: flag("--background") || flag("-b"),
..Default::default()
},
force: flag("--force"),
timeout: None,
},
"install" => Command::Install {
agent: need("agent name")?,
method: method_flag(),
force: flag("--force"),
},
"uninstall" => Command::Uninstall {
agent: need("agent name")?,
purge: flag("--purge"),
},
"update" => {
if flag("--all") {
Command::Update {
agent: None,
all: true,
}
} else {
Command::Update {
agent: rest.first().cloned(),
all: false,
}
}
}
"doctor" => Command::Doctor { fix: flag("--fix") },
"run" => {
let agent = need("agent name")?;
Command::Run {
agent,
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
}
"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(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"
)
}
/// Resolve the path argument of ls/dir (defaults to the current directory).
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
Ok(match rest.first() {
Some(p) => crate::config::expand_path(p),
None => current_dir(),
})
}
/// Print a table and remember it as the last result (for where/get).
/// The table is rendered to fit the terminal width so rows never wrap.
fn show_table(app: &App, last: &mut Option<DataTable>, table: DataTable) {
let width = crate::output::terminal_width().unwrap_or(120);
print!("{}", table.render(app.color(), width));
*last = Some(table);
}
/// Evaluate a Nushell-style pipeline: ls | ps | where | get, in order.
fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<bool> {
let parts: Vec<&str> = line
.split('|')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
let mut table: Option<DataTable> = None;
for part in &parts {
let tokens =
shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?;
if tokens.is_empty() {
continue;
}
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
match cmd {
"ls" => {
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
}
"dir" if cfg!(windows) => {
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
}
"ps" => {
table = Some(ps::ps_table());
}
"where" => {
let base = table
.as_ref()
.ok_or_else(|| anyhow!("'where' needs a table before it"))?;
if rest.len() < 3 {
anyhow::bail!("usage: where <column> <op> <value>");
}
table = Some(crate::tables::filter_where(
base,
&rest[0],
&rest[1],
&rest[2..].join(" "),
)?);
}
"get" => {
let base = table
.as_ref()
.ok_or_else(|| anyhow!("'get' needs a table before it"))?;
let field = rest.first().ok_or_else(|| anyhow!("usage: get <column>"))?;
table = Some(crate::tables::get_column(base, field)?);
}
other => anyhow::bail!(
"'{other}' cannot be used in a pipeline — use ls, ps, where or get"
),
}
}
if let Some(t) = table {
show_table(app, last, t);
}
Ok(false)
}
/// 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!(" ls [path] · dir [path] list files and directories (Nushell-style table)");
println!(" ps list processes");
println!(" where <col> <op> <val> filter the last table (ls | where size > 1mb)");
println!(" get <col> select a column (ls | get name)");
println!(" cd <dir> change directory — Tab completes folders");
println!(" help | version | exit this help, the version, quit");
println!();
println!("pipelines:");
println!(" ls | where size > 1mb | get name");
println!(" ps | where name =~ am");
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. '!ls', '!list')");
println!(" Tab inserts the common prefix and lists candidates with descriptions");
println!();
println!("slash commands: /help · /version · /exit · /shell <name>");
}
#[cfg(test)]
mod tests {
use super::*;
fn completer() -> AmCompleter {
let cfg: crate::config::Config =
serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap();
let catalog = crate::catalog::Catalog::new(
cfg.agents,
cfg.aliases.clone(),
cfg.groups.clone(),
);
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();
let (start, cands) = c.candidates_for("sta", 3);
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]
fn completes_agent_names_and_aliases() {
let c = completer();
let (start, cands) = c.candidates_for("install claude", 14);
assert_eq!(start, 8);
assert!(cands.contains(&"claude-code".to_string()));
let (_, cands) = c.candidates_for("info cc", 7);
assert!(cands.contains(&"cc".to_string()));
}
#[test]
fn completes_group_selectors() {
let c = completer();
let (_, cands) = c.candidates_for("start group:d", 13);
assert!(!cands.is_empty());
assert!(cands.iter().all(|x| x.starts_with("group:")));
assert!(cands.contains(&"group:dev".to_string()));
}
#[test]
fn completes_flags_after_arguments() {
let c = completer();
let (_, cands) = c.candidates_for("install smelt --dr", 18);
assert!(cands.contains(&"--dry-run".to_string()));
}
#[test]
fn completes_config_subcommands() {
let c = completer();
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,
12,
&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("12 external"));
assert!(text.contains("3 managed"));
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);
}
}