//! 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, 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::{Command, StartArgs}; use crate::commands; use crate::nav; use crate::ps; use crate::tables::DataTable; use crate::shell::ShellSession; use crate::theme::Theme; use anyhow::{anyhow, Result}; 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, aliases: Vec, groups: Vec, /// Short descriptions shown next to candidates in the Tab menu /// (Nushell-style). descriptions: BTreeMap, /// Active color theme (used by the boxed menu highlight; follows the /// app theme when it is switched with the REPL 'theme ' command). theme: std::cell::Cell<&'static crate::theme::Theme>, } /// 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"), ("theme", "show or switch the color theme"), ("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 { let completer = Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups); completer.theme.set(app.theme()); completer } /// Point the menu at a new theme (after 'theme ' in the REPL). pub fn set_theme(&self, theme: &'static crate::theme::Theme) { self.theme.set(theme); } pub fn from_catalog( catalog: &crate::catalog::Catalog, aliases: &BTreeMap, groups: &BTreeMap>, ) -> Self { let mut agents: Vec = catalog .agents() .iter() .filter(|a| !a.hidden) .map(|a| a.name.clone()) .collect(); agents.sort(); let mut alias_list: Vec = aliases.keys().cloned().collect(); alias_list.sort(); let mut group_list: Vec = groups.keys().map(|g| format!("group:{g}")).collect(); group_list.sort(); let mut descriptions: BTreeMap = 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 { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ "list", "status", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "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) { 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 = 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(); } "theme" => { pool = crate::theme::names() .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 = 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)> { let (start, cands) = self.candidates_for(line, pos); let pairs = if cands.len() < 2 { // A single match is inserted immediately (no menu). cands .into_iter() .map(|c| Pair { display: c.clone(), replacement: c, }) .collect() } else { self.boxed_pairs(&cands) }; Ok((start, pairs)) } } impl AmCompleter { /// Build the boxed Tab menu: one candidate per box line, so rustyline's /// single-column layout renders the whole menu as one white-framed /// table. Decorative lines (borders, header) replace the typed word /// with the common prefix, so completion behaves exactly as before. fn boxed_pairs(&self, cands: &[String]) -> Vec { let rows: Vec<(String, String)> = cands .iter() .map(|c| (c.clone(), self.descriptions.get(c).cloned().unwrap_or_default())) .collect(); let has_desc = rows.iter().any(|(_, d)| !d.is_empty()); let lines = crate::output::completion_box(&rows); let lcp = common_prefix(cands); let n = cands.len(); let data_start = if has_desc { 3 } else { 1 }; // Pad every line to the terminal width: rustyline then lays the // menu out in one column, one box line per row. let width = crate::output::terminal_width() .unwrap_or(120) .max(lines.iter().map(|l| l.chars().count()).max().unwrap_or(0)); lines .into_iter() .enumerate() .map(|(i, line)| { let replacement = if i >= data_start && i < data_start + n { cands[i - data_start].clone() } else { lcp.clone() }; let display = format!( "{line}{}", " ".repeat(width.saturating_sub(line.chars().count())) ); Pair { display, replacement, } }) .collect() } } /// Longest common prefix of the candidates (chars, not bytes). fn common_prefix(items: &[String]) -> String { let mut iter = items.iter(); let Some(first) = iter.next() else { return String::new(); }; let mut prefix = first.clone(); for s in iter { while !s.starts_with(&prefix) && !prefix.is_empty() { prefix.pop(); } if prefix.is_empty() { break; } } prefix } impl Hinter for AmCompleter { type Hint = String; fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { None } } impl Highlighter for AmCompleter { /// Style the boxed completion menu: borders dim, the header row in the /// theme header style, candidate names in the accent color. fn highlight_candidate<'c>( &self, candidate: &'c str, completion: rustyline::CompletionType, ) -> std::borrow::Cow<'c, str> { use std::borrow::Cow; let _ = completion; match candidate.chars().next() { Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.get().frame(candidate)), Some('│') => { let cells: Vec<&str> = candidate.split('│').collect(); if cells.len() >= 4 { if cells[1].trim() == "name" && cells[2].trim() == "description" { Cow::Owned(self.theme.get().hdr(candidate)) } else { let bar = self.theme.get().frame("│"); Cow::Owned(format!( "{}{}{}{}{}", bar, self.theme.get().acc(cells[1]), bar, cells[2], bar )) } } else { Cow::Borrowed(candidate) } } _ => Cow::Borrowed(candidate), } } } 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), the active color theme, command groups and the stats /// footer. Every color comes from the active theme. pub fn banner_box( version: &str, agents: usize, installed: usize, external: usize, session: &ShellSession, session_id: &str, cwd: &str, theme: &Theme, color: bool, ) -> String { let w = BOX_WIDTH; let mut rows: Vec = Vec::new(); let frame = |s: &str| -> String { if color { theme.frame(s) } else { s.to_string() } }; let inner = |content: String| { format!( "{} {} {}", frame("│"), pad_visible(content, w), frame("│") ) }; let blank = || inner(" ".to_string()); // Top border with the centered header (title in the theme header color, // border in the theme frame color). let title = format!(" Agent Manager v{version} · shell gateway "); let title = if color { theme.hdr(&title) } else { title }; let fill = w + 2 - visible_len(&title); let left = fill / 2; let right = fill - left; rows.push(frame(&format!( "╭{}{}{}╮", "─".repeat(left), title, "─".repeat(right) ))); // Block-art title, painted in the theme accent color. for art in banner_art() { rows.push(inner(if color { theme.acc(&art) } else { art })); } // Shell gateway: the user's default shell is highlighted at startup. rows.push(blank()); rows.push(inner(if color { theme.hdr("Shell Gateway") } else { "Shell Gateway".to_string() })); let current = format!( "◉ {} — {}{}", green(session.current.name, theme, 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}"), theme, color))); rows.push(inner(dim( " system commands run in the active shell — 'shell ' switches", theme, color, ))); // Active color theme: the current one highlighted, every other theme // listed, plus the two ways to switch. rows.push(blank()); rows.push(inner(if color { theme.hdr("Theme") } else { "Theme".to_string() })); let current_theme = format!( "◉ {} — {}", green(theme.name, theme, color), theme.title ); rows.push(inner(format!(" {current_theme}"))); let theme_others: Vec<&str> = crate::theme::THEMES .iter() .map(|t| t.name) .filter(|n| *n != theme.name) .collect(); rows.push(inner(dim( &format!(" ⇄ {}", theme_others.join(" · ")), theme, color, ))); rows.push(inner(dim( " 'am --theme ' to switch · settings.theme in the config", theme, color, ))); // am command groups. rows.push(blank()); rows.push(inner(if color { theme.hdr("Commands") } else { "Commands".to_string() })); 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 · theme · 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 {} · theme {} · /help for commands", session.current.name, theme.name ), theme, 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", theme, color, ))); rows.push(frame(&format!("╰{}╯", "─".repeat(w + 2)))); 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 theme = app.theme(); let mut text = banner_box( env!("CARGO_PKG_VERSION"), agent_count(app), installed_count(app), external_count(app), session, session_id, ¤t_dir_string(), theme, 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:", theme, color), dim( &format!( "system commands run in {} — 'shell ' switches · '!' forces · 'cd' works in this session", session.current.name ), theme, color, ), )); text } /// Color an emoji glyph with the active theme (style names: green, blue, /// yellow, cyan). fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String { if !color { return glyph.to_string(); } match style { "green" => theme.acc(glyph), "blue" => theme.inf(glyph), "yellow" => theme.warn(glyph), "cyan" => theme.inf(glyph), _ => glyph.to_string(), } } /// One-line status shown before every prompt, with colored emojis: /// ⚙ am v0.2.13 │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s /// The last parameter is the duration of the previous command (None before /// the first one). fn print_status(app: &App, started: Instant, last: Option) { let color = app.color(); let theme = app.theme(); let sep = dim(" │ ", theme, color); let cwd = shorten_head(¤t_dir_string(), 30); let folder = format!( "{} {}", emoji("🗂️", "cyan", theme, color), dim(&cwd, theme, color) ); let counts = format!( "{} {} {} {} {} {} {} {}", dim(&agent_count(app).to_string(), theme, color), emoji("🤖", "green", theme, color), dim("·", theme, color), dim(&external_count(app).to_string(), theme, color), emoji("🌐", "blue", theme, color), dim("·", theme, color), dim(&installed_count(app).to_string(), theme, color), emoji("📊", "yellow", theme, color), ); let last_text = match last { Some(d) if d.as_secs() >= 1 => format_elapsed(d), Some(d) if d.as_millis() >= 1 => format!("{}ms", d.as_millis()), Some(d) => format!("{}µs", d.as_micros()), None => "—".to_string(), }; let last_part = format!( "{} {}", emoji("⚡", "yellow", theme, color), dim(&last_text, theme, color) ); let elapsed = dim( &format!("⏲ {}", format_elapsed(started.elapsed())), theme, color, ); println!( "{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}", dim(" ⚙ ", theme, 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, theme: &Theme, color: bool) -> String { if color { theme.dimmed(s) } else { s.to_string() } } fn green(s: &str, theme: &Theme, color: bool) -> String { if color { theme.acc(s) } else { s.to_string() } } fn yellow(s: &str, theme: &Theme, color: bool) -> String { if color { theme.warn(s) } 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 = 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 { // 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. crate::help::print_general(app); 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 { let mut rl: rustyline::Editor = 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 = None; let mut last_duration: Option = None; loop { print_status(app, started, last_duration); match rl.readline("❯ ") { Ok(line) => { let trimmed = line.trim().to_string(); if trimmed.is_empty() { continue; } let _ = rl.add_history_entry(trimmed.as_str()); let t = Instant::now(); let result = handle_input(app, session, &mut last, &trimmed); last_duration = Some(t.elapsed()); // 'theme ' may have switched the app theme: repoint the // completion menu at the new palette. if let Some(h) = rl.helper_mut() { h.set_theme(app.theme()); } match result { 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 { let started = Instant::now(); let mut last: Option = None; let mut last_duration: Option = None; loop { print_status(app, started, last_duration); 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; } let t = Instant::now(); let result = handle_input(app, session, &mut last, &line); last_duration = Some(t.elapsed()); match result { 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, line: &str, ) -> Result { match line { "exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true), "help" | "?" | "/help" => { crate::help::print_general(app); return Ok(false); } "version" | "/version" => { crate::help::print_version(app); return Ok(false); } "theme" | "/theme" => { theme_command(app, None); 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), "theme" => theme_command(app, arg), "help" => match arg { Some(topic) => { if let Err(e) = crate::help::print_command(app, topic) { app.log.error(format!("{e:#}")); } } None => crate::help::print_general(app), }, _ => 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, line: &str, ) -> Result { // 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..]; // Nushell-style help: 'ls -h', 'ls --help', 'start -h', ... show the // colored command help. System commands keep their own -h handling. if is_am_command(cmd) || is_shell_command(cmd) { let help_flag_pos = rest.iter().position(|t| t == "-h" || t == "--help"); let dd_pos = rest.iter().position(|t| t == "--"); let help_requested = match help_flag_pos { Some(p) => dd_pos.is_none_or(|d| p < d), None => false, }; // 'run --help' passes --help through to the agent; only // 'run -h' right after the command name is a help request. let passthrough = cmd == "run" && help_flag_pos != Some(0); if help_requested && !passthrough { if let Err(e) = crate::help::print_command(app, cmd) { app.log.error(format!("{e:#}")); } return Ok(false); } } // help | help --find | help commands if cmd == "help" { return run_help_command(app, rest); } if cmd == "version" { crate::help::print_version(app); return Ok(false); } // theme | theme : list the themes or switch the active one. if cmd == "theme" { theme_command(app, rest.first().map(|s| s.as_str())); return Ok(false); } // 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 "); } 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 "))?; 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 { 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 REPL's shell-level commands (ls, ps, ...). fn is_shell_command(word: &str) -> bool { matches!( word, "ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "help" | "version" | "exit" ) } /// 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 { // A leading '--' ends flag parsing, so 'ls -- -h' can list a file // literally named "-h" instead of showing the help. let args: Vec<&String> = if rest.first().map(String::as_str) == Some("--") { rest.iter().skip(1).collect() } else { rest.iter().collect() }; Ok(match args.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, table: DataTable) { let width = crate::output::terminal_width().unwrap_or(120); print!( "{}", table.render_themed(app.theme(), 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, line: &str) -> Result { let parts: Vec<&str> = line .split('|') .map(str::trim) .filter(|p| !p.is_empty()) .collect(); let mut table: Option = 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..]; // Nushell-style: 'ls -h' inside a pipeline shows the command help. if rest .iter() .take_while(|t| t.as_str() != "--") .any(|t| t == "-h" || t == "--help") && matches!(cmd, "ls" | "dir" | "ps" | "where" | "get") { if let Err(e) = crate::help::print_command(app, cmd) { app.log.error(format!("{e:#}")); } return Ok(false); } 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 "); } 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 "))?; 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), }, } } /// 'theme' in the REPL: list the themes, or switch with 'theme '. fn theme_command(app: &App, arg: Option<&str>) { match arg.map(str::trim).filter(|s| !s.is_empty()) { None => print_theme_status(app), Some(name) => match crate::theme::find(name) { Some(t) => { app.set_theme(t); let color = app.color(); println!( "{} {}", if color { t.hdr("theme:") } else { "theme:".to_string() }, format!("{} — {}", t.acc(t.name), t.title) ); } None => app.log.error(format!( "unknown theme '{name}' — available: {}", crate::theme::names().join(", ") )), }, } } /// List the available themes, highlighting the active one. fn print_theme_status(app: &App) { let theme = app.theme(); let color = app.color(); let hdr = |s: &str| -> String { if color { theme.hdr(s) } else { s.to_string() } }; println!("{}", hdr("Theme")); println!( " ◉ {} — {}", theme.acc(theme.name), theme.title ); let others: Vec<&str> = crate::theme::THEMES .iter() .map(|t| t.name) .filter(|n| *n != theme.name) .collect(); println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · ")))); println!( " {}", theme.dimmed( "'theme ' to switch · '--theme ' or settings.theme to change the default" ) ); } 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"); } /// 'help' in the REPL: the overview, a command/agent topic, the command /// list, or a --find search — the same Nushell-style help as 'am help'. fn run_help_command(app: &App, rest: &[String]) -> Result { let mut iter = rest.iter(); match iter.next().map(|s| s.as_str()) { None => crate::help::print_general(app), Some("--find") => { let text = iter.next().map(|s| s.as_str()).unwrap_or(""); crate::help::print_find(app, text); } Some("-h") | Some("--help") => { crate::help::print_command(app, "help")?; } Some(topic) => { crate::help::print_command(app, topic)?; } } Ok(false) } #[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", crate::theme::default_theme(), 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("12 external")); assert!(text.contains("3 managed")); assert!(text.contains("agent-manager")); // The splash screen shows the active theme and every other theme. assert!(text.contains("Theme")); assert!(text.contains("◉ default")); assert!(text.contains("ocean")); assert!(text.contains("sunset")); assert!(text.contains("forest")); assert!(text.contains("dracula")); assert!(text.contains("mono")); assert!(text.contains("theme default")); assert!(text.contains("--theme ")); } #[test] fn banner_box_is_themed_and_still_aligned() { let session = test_session(); let ocean = crate::theme::find("ocean").unwrap(); let text = banner_box( "9.9.9", 65, 3, 12, &session, "20260815_123456_abcdef", "/home/alice/dev/agent-manager", ocean, true, ); // ANSI color codes are present with the ocean theme... assert!(text.contains("\x1b["), "no ANSI in themed banner:\n{text}"); assert!(strip_ansi(&text).contains("◉ ocean")); // ...and the visible box stays aligned (strip the ANSI codes). let widths: Vec = text .lines() .map(|l| strip_ansi(l).chars().count()) .collect(); assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}"); } #[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); } }