1320 lines
44 KiB
Rust
1320 lines
44 KiB
Rust
//! 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,
|
||
¤t_dir_string(),
|
||
color,
|
||
);
|
||
text.push('\n');
|
||
text.push_str(
|
||
"Welcome to Agent Manager! Type an am command or any system command — /help for commands.\n",
|
||
);
|
||
text.push_str(&format!(
|
||
"{} {}\n",
|
||
yellow("✦ Tip:", color),
|
||
dim(
|
||
&format!(
|
||
"system commands run in {} — 'shell <name>' switches · '!' forces · 'cd' works in this session",
|
||
session.current.name
|
||
),
|
||
color,
|
||
),
|
||
));
|
||
text
|
||
}
|
||
|
||
/// One-line status shown before every prompt (Hermes-style status bar).
|
||
fn print_status(app: &App, session: &ShellSession, started: Instant) {
|
||
let color = app.color();
|
||
let sep = dim(" │ ", color);
|
||
let cwd = shorten_head(¤t_dir_string(), 26);
|
||
let shell_part = format!(
|
||
"{} {}",
|
||
bold_cyan(session.current.name, color),
|
||
dim(&cwd, color)
|
||
);
|
||
let counts = dim(
|
||
&format!(
|
||
"{} agents · {} 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);
|
||
}
|
||
}
|