Files
agent-manager/src/help.rs
T

1393 lines
48 KiB
Rust

//! Nushell-style help: every command documented as a spec, rendered like
//! 'nu help <command>' (description, search terms, usage, flags, command
//! type, parameters, input/output types, examples) with colors.
//!
//! Also provides the version table ('am version', Nushell-style) and the
//! pre-parsing interception of -h/--help flags so that 'am install -h',
//! 'am ls --help' and friends all show this help instead of the clap
//! generated one.
use crate::app::App;
use crate::theme::Theme;
use anyhow::bail;
use serde::Serialize;
use std::io::IsTerminal;
// ---------------------------------------------------------------------------
// Command specifications
// ---------------------------------------------------------------------------
pub struct HelpFlag {
pub short: &'static str,
pub long: &'static str,
pub value: &'static str,
pub desc: &'static str,
}
pub struct HelpParam {
pub name: &'static str,
pub typ: &'static str,
pub desc: &'static str,
}
pub struct HelpExample {
pub desc: &'static str,
pub code: &'static str,
}
pub struct HelpSpec {
pub name: &'static str,
pub category: &'static str,
pub usage: &'static str,
pub about: &'static str,
pub search_terms: &'static [&'static str],
pub flags: &'static [HelpFlag],
pub subcommands: &'static [(&'static str, &'static str)],
pub parameters: &'static [HelpParam],
pub io: Option<(&'static str, &'static str)>,
pub examples: &'static [HelpExample],
}
const FLAG_HELP: HelpFlag = HelpFlag {
short: "-h",
long: "--help",
value: "",
desc: "Display the help message for this command",
};
/// Flags shared by every am command.
const GLOBAL_FLAGS: &[HelpFlag] = &[
HelpFlag {
short: "-c",
long: "--config",
value: "FILE",
desc: "Use an alternate configuration file",
},
HelpFlag {
short: "-v",
long: "--verbose",
value: "",
desc: "Verbose output: show every command executed and its details",
},
HelpFlag {
short: "-q",
long: "--quiet",
value: "",
desc: "Quiet mode: only errors are printed",
},
HelpFlag {
short: "-y",
long: "--yes",
value: "",
desc: "Answer yes to every confirmation prompt",
},
HelpFlag {
short: "",
long: "--dry-run",
value: "",
desc: "Simulate the action without changing anything",
},
HelpFlag {
short: "",
long: "--json",
value: "",
desc: "Emit machine-readable JSON on stdout (for scripts)",
},
HelpFlag {
short: "",
long: "--no-color",
value: "",
desc: "Disable ANSI colors",
},
HelpFlag {
short: "",
long: "--theme",
value: "THEME",
desc: "Color theme for the output: default, ocean, sunset, forest, dracula, mono",
},
];
const START_FLAGS: &[HelpFlag] = &[
HelpFlag {
short: "-b",
long: "--background",
value: "",
desc: "Run detached in the background; output goes to the agent log file",
},
HelpFlag {
short: "-f",
long: "--foreground",
value: "",
desc: "Run in the foreground, attached to this terminal (the default)",
},
HelpFlag {
short: "",
long: "--args",
value: "ARGS",
desc: "Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)",
},
HelpFlag {
short: "",
long: "--env",
value: "KEY=VALUE",
desc: "Set an environment variable for the agent (repeatable)",
},
HelpFlag {
short: "",
long: "--notify",
value: "",
desc: "Send a desktop notification once the agent has started",
},
];
pub static HELP_SPECS: &[HelpSpec] = &[
HelpSpec {
name: "list",
category: "Commands",
usage: "list {flags}",
about: "List installed agents, or every catalog entry with --all.",
search_terms: &["catalog", "agents", "installed"],
flags: &[
HelpFlag { short: "", long: "--all", value: "", desc: "Show every agent known to the catalog, not only the installed ones" },
HelpFlag { short: "", long: "--category", value: "CATEGORY", desc: "Only show agents of this category" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "List the installed agents.", code: "list" },
HelpExample { desc: "Show every agent in the catalog.", code: "list --all" },
HelpExample { desc: "Show only one category.", code: "list --category coding-agent" },
],
},
HelpSpec {
name: "status",
category: "Commands",
usage: "status {flags} [agent]",
about: "Show the state of one agent, or of every installed agent.",
search_terms: &["state", "running", "pid"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (omit to show all installed agents)" },
],
io: None,
examples: &[
HelpExample { desc: "Show the state of every installed agent.", code: "status" },
HelpExample { desc: "Show one agent.", code: "status claude-code" },
],
},
HelpSpec {
name: "search",
category: "Commands",
usage: "search {flags} <keyword>",
about: "Search the catalog by keyword (name, description, category, tags).",
search_terms: &["find", "catalog", "keyword"],
flags: &[
HelpFlag { short: "", long: "--category", value: "CATEGORY", desc: "Restrict to this category" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "keyword", typ: "string", desc: "Keyword to search for (case-insensitive substring)" },
],
io: None,
examples: &[
HelpExample { desc: "Find agents related to rust.", code: "search rust" },
HelpExample { desc: "Search within one category.", code: "search coding --category coding-agent" },
],
},
HelpSpec {
name: "info",
category: "Commands",
usage: "info {flags} <agent>",
about: "Show detailed information about one agent.",
search_terms: &["details", "show", "agent"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "Show details about an agent.", code: "info claude-code" },
HelpExample { desc: "Use an alias.", code: "info cc" },
],
},
HelpSpec {
name: "install",
category: "Commands",
usage: "install {flags} <agent>",
about: "Install an agent and its dependencies.",
search_terms: &["add", "setup"],
flags: &[
HelpFlag { short: "", long: "--method", value: "METHOD", desc: "Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)" },
HelpFlag { short: "", long: "--force", value: "", desc: "Reinstall even if already installed" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "Install an agent with its dependencies.", code: "install claude-code" },
HelpExample { desc: "Pick a specific install method.", code: "install smelt --method uv" },
HelpExample { desc: "Reinstall over an existing install.", code: "install claude-code --force" },
],
},
HelpSpec {
name: "uninstall",
category: "Commands",
usage: "uninstall {flags} <agent>",
about: "Uninstall an agent (managed or external) and clean its files.",
search_terms: &["remove", "delete"],
flags: &[
HelpFlag { short: "", long: "--purge", value: "", desc: "Also remove logs and the agent entry from the user config file" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "Remove an agent.", code: "uninstall claude-code" },
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
],
},
HelpSpec {
name: "update",
category: "Commands",
usage: "update {flags} [agent]",
about: "Update an installed agent to the latest available version.",
search_terms: &["upgrade"],
flags: &[
HelpFlag { short: "", long: "--all", value: "", desc: "Update every installed managed agent" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (required unless --all)" },
],
io: None,
examples: &[
HelpExample { desc: "Update one agent.", code: "update claude-code" },
HelpExample { desc: "Update every managed agent.", code: "update --all" },
],
},
HelpSpec {
name: "start",
category: "Commands",
usage: "start {flags} <agent>",
about: "Start an agent (foreground by default, or detached with --background).",
search_terms: &["launch", "begin"],
flags: START_FLAGS,
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name, alias, or group:<name>" },
],
io: None,
examples: &[
HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" },
HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" },
HelpExample { desc: "Start every agent of a group.", code: "start group:dev --background" },
],
},
HelpSpec {
name: "stop",
category: "Commands",
usage: "stop {flags} <agent>",
about: "Stop a background agent (SIGTERM, then SIGKILL after the timeout).",
search_terms: &["kill", "terminate"],
flags: &[
HelpFlag { short: "", long: "--force", value: "", desc: "Kill immediately instead of terminating gracefully" },
HelpFlag { short: "", long: "--timeout", value: "SECS", desc: "Grace period in seconds before killing (default: from config, 5s)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name, alias, or group:<name>" },
],
io: None,
examples: &[
HelpExample { desc: "Stop an agent gracefully.", code: "stop claude-code" },
HelpExample { desc: "Kill immediately.", code: "stop claude-code --force" },
],
},
HelpSpec {
name: "restart",
category: "Commands",
usage: "restart {flags} <agent>",
about: "Restart an agent: stop, then start with the same options.",
search_terms: &["reboot", "reload"],
flags: &[
HelpFlag { short: "-b", long: "--background", value: "", desc: "Restart detached in the background; output goes to the agent log file" },
HelpFlag { short: "-f", long: "--foreground", value: "", desc: "Restart in the foreground, attached to this terminal (the default)" },
HelpFlag { short: "", long: "--args", value: "ARGS", desc: "Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)" },
HelpFlag { short: "", long: "--env", value: "KEY=VALUE", desc: "Set an environment variable for the agent (repeatable)" },
HelpFlag { short: "", long: "--notify", value: "", desc: "Send a desktop notification once the agent has started" },
HelpFlag { short: "", long: "--force", value: "", desc: "Kill immediately instead of terminating gracefully" },
HelpFlag { short: "", long: "--timeout", value: "SECS", desc: "Grace period in seconds before killing (default: from config, 5s)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name, alias, or group:<name>" },
],
io: None,
examples: &[
HelpExample { desc: "Restart an agent in the background.", code: "restart claude-code --background" },
HelpExample { desc: "Restart with extra arguments.", code: "restart claude-code --args \"--model sonnet\"" },
],
},
HelpSpec {
name: "run",
category: "Commands",
usage: "run {flags} <agent> ...(args)",
about: "Run the agent command directly with the given arguments (no process management).",
search_terms: &["exec", "pass-through"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
HelpParam { name: "args", typ: "any", desc: "Arguments passed through to the agent command" },
],
io: None,
examples: &[
HelpExample { desc: "Run an agent command directly.", code: "run claude-code" },
HelpExample { desc: "Pass --help through to the agent (after --).", code: "run claude-code -- --help" },
],
},
HelpSpec {
name: "doctor",
category: "Commands",
usage: "doctor {flags}",
about: "Check the environment: tools, config validity, paths, permissions.",
search_terms: &["check", "environment", "repair"],
flags: &[
HelpFlag { short: "", long: "--fix", value: "", desc: "Attempt to repair problems (create directories, fix the state file)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Check the environment.", code: "doctor" },
HelpExample { desc: "Check and repair.", code: "doctor --fix" },
],
},
HelpSpec {
name: "config",
category: "Commands",
usage: "config {flags} <subcommand>",
about: "Manage the configuration file.",
search_terms: &["settings", "yaml", "configure"],
flags: &[],
subcommands: &[
("show", "Print the effective (merged) configuration as YAML"),
("path", "Print the location of the configuration file(s)"),
("edit", "Open the user configuration file in your editor"),
("validate", "Validate the configuration file(s)"),
("add <file>", "Add another agent definition file to the user configuration"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Show the effective configuration.", code: "config show" },
HelpExample { desc: "Locate the config file.", code: "config path" },
HelpExample { desc: "Validate it.", code: "config validate" },
],
},
HelpSpec {
name: "completion",
category: "Commands",
usage: "completion {flags} <shell>",
about: "Generate a shell completion script (bash, zsh, fish, powershell, elvish).",
search_terms: &["autocomplete", "shell"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "shell", typ: "oneof<bash, zsh, fish, powershell, elvish>", desc: "Shell to generate completions for" },
],
io: None,
examples: &[
HelpExample { desc: "Generate PowerShell completions.", code: "completion powershell" },
],
},
HelpSpec {
name: "self-update",
category: "Commands",
usage: "self-update {flags}",
about: "Update agent-manager itself from GitHub Releases.",
search_terms: &["upgrade", "release"],
flags: &[
HelpFlag { short: "", long: "--check", value: "", desc: "Only check whether a newer version exists" },
HelpFlag { short: "", long: "--to", value: "FILE", desc: "Download the new binary to this path instead of replacing the current one" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Check for a newer version.", code: "self-update --check" },
HelpExample { desc: "Download without replacing the current binary.", code: "self-update --to ./am-new.exe" },
],
},
HelpSpec {
name: "self-uninstall",
category: "Commands",
usage: "self-uninstall {flags}",
about: "Remove agent-manager and everything it created from this machine.",
search_terms: &["remove", "cleanup"],
flags: &[],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Remove agent-manager completely.", code: "self-uninstall --yes" },
],
},
HelpSpec {
name: "export",
category: "Commands",
usage: "export {flags}",
about: "Export the configuration and installation state (backup).",
search_terms: &["backup", "save"],
flags: &[
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Output file (default: am-export.json)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Back up to the default file.", code: "export" },
HelpExample { desc: "Choose the output file.", code: "export --output backup.json" },
],
},
HelpSpec {
name: "import",
category: "Commands",
usage: "import {flags} <file>",
about: "Import a previously exported configuration and state.",
search_terms: &["restore", "backup"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "file", typ: "path", desc: "File exported by the export command" },
],
io: None,
examples: &[
HelpExample { desc: "Restore a backup.", code: "import am-export.json" },
],
},
HelpSpec {
name: "help",
category: "Commands",
usage: "help {flags} [name]",
about: "Display help: the overview, a particular command or agent, the command list, or a search through it.",
search_terms: &["man"],
flags: &[
HelpFlag { short: "", long: "--find", value: "TEXT", desc: "Search through all help commands table" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "name", typ: "string", desc: "Command name, agent name, or alias; 'commands' lists every command" },
],
io: None,
examples: &[
HelpExample { desc: "Show the overview.", code: "help" },
HelpExample { desc: "Show one command.", code: "help install" },
HelpExample { desc: "List every command.", code: "help commands" },
HelpExample { desc: "Search the help.", code: "help --find install" },
],
},
HelpSpec {
name: "version",
category: "Commands",
usage: "version {flags}",
about: "Display version and build information.",
search_terms: &["about"],
flags: &[],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Show version and build information.", code: "version" },
],
},
HelpSpec {
name: "theme",
category: "Shell",
usage: "theme {flags} [name]",
about: "Show the color themes, or switch the active one inside the shell.",
search_terms: &["color", "colors", "palette"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "name", typ: "string", desc: "Theme to switch to (default, ocean, sunset, forest, dracula, mono); omit to list them" },
],
io: None,
examples: &[
HelpExample { desc: "List the available themes.", code: "theme" },
HelpExample { desc: "Switch to the ocean theme.", code: "theme ocean" },
],
},
HelpSpec {
name: "ls",
category: "Shell",
usage: "ls {flags} [path]",
about: "List the filenames, sizes, and modification times of items in a directory.",
search_terms: &["dir", "files", "directory"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "path", typ: "path", desc: "The directory or glob to list (default: current directory)" },
],
io: Some(("nothing", "table")),
examples: &[
HelpExample { desc: "List the current directory.", code: "ls" },
HelpExample { desc: "List a subdirectory.", code: "ls src" },
HelpExample { desc: "List Rust files.", code: "ls *.rs" },
HelpExample { desc: "List files larger than 1 MB.", code: "ls | where size > 1mb" },
HelpExample { desc: "Select one column.", code: "ls | get name" },
],
},
HelpSpec {
name: "dir",
category: "Shell",
usage: "dir {flags} [path]",
about: "List the filenames, sizes, and modification times of items in a directory (Windows alias of ls).",
search_terms: &["ls", "files", "directory"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "path", typ: "path", desc: "The directory or glob to list (default: current directory)" },
],
io: Some(("nothing", "table")),
examples: &[
HelpExample { desc: "List the current directory.", code: "dir" },
HelpExample { desc: "List a subdirectory.", code: "dir src" },
],
},
HelpSpec {
name: "ps",
category: "Shell",
usage: "ps {flags}",
about: "List the processes on the system.",
search_terms: &["processes", "task"],
flags: &[],
subcommands: &[],
parameters: &[],
io: Some(("nothing", "table")),
examples: &[
HelpExample { desc: "List all processes.", code: "ps" },
HelpExample { desc: "Filter by name.", code: "ps | where name =~ am" },
],
},
HelpSpec {
name: "where",
category: "Shell",
usage: "where {flags} <column> <operator> <value>",
about: "Filter the rows of the last table (ls or ps) with a predicate.",
search_terms: &["filter", "query"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "column", typ: "string", desc: "Column to filter on" },
HelpParam { name: "operator", typ: "string", desc: "One of > < >= <= == != =~" },
HelpParam { name: "value", typ: "string", desc: "Value to compare (supports 1kb/1mb/1gb sizes)" },
],
io: Some(("table", "table")),
examples: &[
HelpExample { desc: "Keep files larger than 1 MB.", code: "ls | where size > 1mb" },
HelpExample { desc: "Keep processes whose name contains 'am'.", code: "ps | where name =~ am" },
],
},
HelpSpec {
name: "get",
category: "Shell",
usage: "get {flags} <column>",
about: "Select one column of the last table.",
search_terms: &["select", "column"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "column", typ: "string", desc: "Column to select" },
],
io: Some(("table", "table")),
examples: &[
HelpExample { desc: "Select the name column.", code: "ls | get name" },
],
},
HelpSpec {
name: "cd",
category: "Shell",
usage: "cd {flags} [dir]",
about: "Change the session directory (Tab completes folders).",
search_terms: &["chdir", "directory"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "dir", typ: "path", desc: "Target directory (default: home)" },
],
io: None,
examples: &[
HelpExample { desc: "Go to a directory.", code: "cd src" },
HelpExample { desc: "Go home.", code: "cd" },
HelpExample { desc: "Go home explicitly.", code: "cd ~" },
],
},
HelpSpec {
name: "shell",
category: "Shell",
usage: "shell {flags} [name]",
about: "Show or switch the system shell used by the gateway.",
search_terms: &["pwsh", "bash", "gateway"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "name", typ: "string", desc: "Shell to switch to (pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish); omit to show the status" },
],
io: None,
examples: &[
HelpExample { desc: "Show the active shell.", code: "shell" },
HelpExample { desc: "Switch to bash.", code: "shell bash" },
],
},
HelpSpec {
name: "exit",
category: "Shell",
usage: "exit {flags}",
about: "Quit the interactive shell.",
search_terms: &["quit", "q"],
flags: &[],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Leave the shell.", code: "exit" },
],
},
];
/// Find a help spec by command name (case-insensitive).
pub fn find_spec(name: &str) -> Option<&'static HelpSpec> {
let lower = name.to_lowercase();
HELP_SPECS.iter().find(|s| s.name.to_lowercase() == lower)
}
/// All specs matching a --find keyword (name, about, usage, search terms).
fn matching_specs(text: &str) -> Vec<&'static HelpSpec> {
let needle = text.to_lowercase();
HELP_SPECS
.iter()
.filter(|s| {
s.name.to_lowercase().contains(&needle)
|| s.about.to_lowercase().contains(&needle)
|| s.usage.to_lowercase().contains(&needle)
|| s.search_terms
.iter()
.any(|t| t.to_lowercase().contains(&needle))
})
.collect()
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
fn header(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.hdr(s)
} else {
s.to_string()
}
}
fn prompt(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.acc(s)
} else {
s.to_string()
}
}
fn name_style(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.acc(s)
} else {
s.to_string()
}
}
fn type_style(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.inf(s)
} else {
s.to_string()
}
}
fn dim(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.dimmed(s)
} else {
s.to_string()
}
}
fn flag_line(f: &HelpFlag, theme: &Theme, color: bool) -> String {
let name = match (f.short.is_empty(), f.long.is_empty()) {
(true, false) => f.long.to_string(),
(false, true) => f.short.to_string(),
_ => format!("{}, {}", f.short, f.long),
};
let name = name_style(&name, theme, color);
let value = if f.value.is_empty() {
String::new()
} else {
format!(" {}", type_style(&format!("<{}>", f.value), theme, color))
};
format!(" {name}{value}: {}\n", f.desc)
}
/// The small '# | input | output' table shown in command help.
fn io_table(input: &str, output: &str, theme: &Theme, color: bool) -> String {
let headers = ["#", "input", "output"];
let cells = ["0", input, output];
let widths: Vec<usize> = headers
.iter()
.zip(cells.iter())
.map(|(h, c)| h.chars().count().max(c.chars().count()))
.collect();
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
for (i, w) in widths.iter().enumerate() {
if i > 0 {
line.push(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push(right);
if color {
theme.frame(&line)
} else {
line
}
};
let bar = |s: &str| -> String {
if color {
theme.frame(s)
} else {
s.to_string()
}
};
let row = |cells: &[&str], bold: bool| -> String {
let mut line = bar("│");
for (i, c) in cells.iter().enumerate() {
line.push(' ');
let text = if bold {
if color {
theme.hdr(c)
} else {
(*c).to_string()
}
} else {
(*c).to_string()
};
line.push_str(&text);
line.push_str(&" ".repeat(widths[i] - c.chars().count()));
line.push(' ');
line.push_str(&bar("│"));
}
line
};
format!(
"{}\n{}\n{}\n{}\n{}",
border('╭', '┬', '╮'),
row(&headers, true),
border('├', '┼', '┤'),
row(&cells, false),
border('╰', '┴', '╯')
)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut cut: String = s.chars().take(max.saturating_sub(1)).collect();
cut.push('…');
cut
}
}
// ---------------------------------------------------------------------------
// Command help
// ---------------------------------------------------------------------------
/// Render one command's help, Nushell-style.
pub fn render_command(spec: &HelpSpec, theme: &Theme, color: bool) -> String {
let mut out = String::new();
// Description.
for line in spec.about.lines() {
out.push_str(line);
out.push('\n');
}
out.push('\n');
// Search terms.
if !spec.search_terms.is_empty() {
out.push_str(&dim(
&format!("Search terms: {}", spec.search_terms.join(", ")),
theme,
color,
));
out.push('\n');
out.push('\n');
}
// Usage.
out.push_str(&format!("{}:\n", header("Usage", theme, color)));
out.push_str(&format!(" {} {}\n", prompt(">", theme, color), spec.usage));
out.push('\n');
// Flags.
out.push_str(&format!("{}:\n", header("Flags", theme, color)));
out.push_str(&flag_line(&FLAG_HELP, theme, color));
for f in spec.flags {
out.push_str(&flag_line(f, theme, color));
}
out.push_str(&format!("{}:\n", header("Global Flags", theme, color)));
for f in GLOBAL_FLAGS {
out.push_str(&flag_line(f, theme, color));
}
out.push('\n');
// Command type.
out.push_str(&format!("{}:\n", header("Command Type", theme, color)));
out.push_str(&format!(
" {}\n",
if color {
theme.acc("built-in")
} else {
"built-in".to_string()
}
));
out.push('\n');
// Subcommands.
if !spec.subcommands.is_empty() {
out.push_str(&format!("{}:\n", header("Subcommands", theme, color)));
for (sub, desc) in spec.subcommands {
out.push_str(&format!(" {}: {desc}\n", name_style(sub, theme, color)));
}
out.push('\n');
}
// Parameters.
if !spec.parameters.is_empty() {
out.push_str(&format!("{}:\n", header("Parameters", theme, color)));
for p in spec.parameters {
let name = name_style(p.name, theme, color);
let typ = type_style(&format!("<{}>", p.typ), theme, color);
out.push_str(&format!(" {name} {typ}: {}\n", p.desc));
}
out.push('\n');
}
// Input/output types.
if let Some((input, output)) = spec.io {
out.push_str(&format!(
"{}:\n",
header("Input/output types", theme, color)
));
for line in io_table(input, output, theme, color).lines() {
out.push_str(&format!(" {line}\n"));
}
out.push('\n');
}
// Examples.
if !spec.examples.is_empty() {
out.push_str(&format!("{}:\n", header("Examples", theme, color)));
for (i, ex) in spec.examples.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!(" {}\n", ex.desc));
out.push_str(&format!(" {} {}\n", prompt(">", theme, color), ex.code));
}
}
out
}
/// Print the help of one command; agent names and aliases fall back to
/// the 'info' command (Nushell's "help <name> for commands, aliases and
/// modules" behavior).
pub fn print_command(app: &App, name: &str) -> Result<i32, anyhow::Error> {
if let Some(spec) = find_spec(name) {
print!("{}", render_command(spec, app.theme(), app.color()));
return Ok(0);
}
if app.catalog.resolve(name).is_some() {
return crate::commands::info_cmd::run(app, name);
}
bail!(
"unknown help topic '{name}' — try 'am help commands' or 'am help --find <text>'"
)
}
// ---------------------------------------------------------------------------
// General help (the 'help' overview, like 'nu help')
// ---------------------------------------------------------------------------
/// Render the general help overview.
pub fn render_general(theme: &Theme, color: bool) -> String {
let bullet = if color {
theme.warn("*")
} else {
"*".to_string()
};
let mut out = String::new();
out.push_str(&format!(
"{}\n\n",
if color {
theme.hdr("Welcome to Agent Manager.")
} else {
"Welcome to Agent Manager.".to_string()
}
));
out.push_str("Here are some tips to help you get started.\n");
out.push_str(&format!(" {bullet} help -h or help help - show available 'help' subcommands and examples\n"));
out.push_str(&format!(" {bullet} help commands - list all available commands\n"));
out.push_str(&format!(" {bullet} help <name> - display help about a particular command or agent\n"));
out.push_str(&format!(" {bullet} help --find <text to search> - search through all help commands table\n"));
out.push_str(&format!(
" {bullet} am --theme <name> - choose a color theme ({})\n",
crate::theme::names().join(", ")
));
out.push('\n');
out.push_str(
"am works on the idea of a \"catalog\". Catalog entries describe AI coding\n\
agents: how to install them, which dependencies they need, and how to start\n\
them. am installs, starts, stops and updates those agents, and its\n\
interactive prompt doubles as a gateway to your system shell.\n",
);
out.push('\n');
out.push_str(&format!("{}\n\n", header("[Examples]", theme, color)));
let example = |desc: &str, code: &str, out: &mut String| {
out.push_str(&format!(" {desc}\n"));
out.push_str(&format!(" {} {code}\n\n", prompt(">", theme, color)));
};
example("List the installed agents:", "am list", &mut out);
example(
"Install an agent and its dependencies:",
"am install claude-code",
&mut out,
);
example(
"Start an agent in the background:",
"am start claude-code --background",
&mut out,
);
example("Search the catalog:", "am search coding", &mut out);
out.push_str(
"Type 'am help commands' for the complete command list, or run 'am' alone for\n\
the interactive shell.\n",
);
out
}
/// Print the general help overview.
pub fn print_general(app: &App) {
print!("{}", render_general(app.theme(), app.color()));
}
/// Print a help topic: None → overview, "commands" → the command list,
/// anything else → the command/agent help.
pub fn print_topic(app: &App, topic: Option<&str>) -> Result<i32, anyhow::Error> {
match topic.map(str::trim).filter(|s| !s.is_empty()) {
None => {
print_general(app);
Ok(0)
}
Some("commands") => {
print_commands_list(app);
Ok(0)
}
Some(name) => print_command(app, name),
}
}
/// The 'help commands' table (also used by 'help --find').
pub fn print_commands_list(app: &App) {
let specs: Vec<&HelpSpec> = HELP_SPECS.iter().collect();
print_specs_table(app, &specs);
}
/// 'help --find <text>': search through all help commands.
pub fn print_find(app: &App, text: &str) {
let hits = matching_specs(text);
if hits.is_empty() {
app.log
.info(format!("no help matches '{text}' — try 'am help commands'"));
return;
}
print_specs_table(app, &hits);
}
fn print_specs_table(app: &App, specs: &[&HelpSpec]) {
let color = app.color();
let theme = app.theme();
let width = crate::output::terminal_width().unwrap_or(120);
let table = crate::tables::DataTable {
columns: vec![
"name".to_string(),
"category".to_string(),
"usage".to_string(),
"description".to_string(),
],
rows: specs
.iter()
.map(|s| {
vec![
crate::tables::Cell::str(name_style(s.name, theme, color)),
crate::tables::Cell::str(s.category.to_string()),
crate::tables::Cell::str(s.usage.to_string()),
crate::tables::Cell::str(truncate(
s.about.lines().next().unwrap_or(""),
48,
)),
]
})
.collect(),
};
print!("{}", table.render_themed(theme, color, width));
}
// ---------------------------------------------------------------------------
// Version (Nushell-style table)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct VersionInfo {
pub version: String,
pub major: u64,
pub minor: u64,
pub patch: u64,
pub branch: String,
pub commit_hash: String,
pub build_os: String,
pub build_target: String,
pub rust_version: String,
pub rust_channel: String,
pub cargo_version: String,
pub build_time: String,
pub build_rust_channel: String,
pub allocator: String,
}
/// Collect the version and build information shown by 'am version'.
pub fn version_info() -> VersionInfo {
let version = env!("CARGO_PKG_VERSION").to_string();
let mut parts = version.split('.');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let (rust_version, rust_channel) = rust_info();
VersionInfo {
version,
major,
minor,
patch,
branch: option_env!("AM_GIT_BRANCH").unwrap_or("").to_string(),
commit_hash: option_env!("AM_GIT_COMMIT").unwrap_or("").to_string(),
build_os: build_os(),
build_target: build_target(),
rust_version,
rust_channel,
cargo_version: tool_version("cargo", &["--version"]).unwrap_or_default(),
build_time: build_time(),
build_rust_channel: if cfg!(debug_assertions) {
"debug".to_string()
} else {
"release".to_string()
},
allocator: "standard".to_string(),
}
}
fn tool_version(program: &str, args: &[&str]) -> Option<String> {
std::process::Command::new(program)
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
fn line_of(text: &str, key: &str) -> Option<String> {
text.lines()
.find_map(|l| l.trim().strip_prefix(key).map(|v| v.trim().to_string()))
}
fn rust_info() -> (String, String) {
let version = tool_version("rustc", &["--version"]).unwrap_or_default();
let channel = tool_version("rustc", &["-vV"])
.and_then(|v| {
let release = line_of(&v, "release:")?;
match line_of(&v, "host:") {
Some(host) => Some(format!("{release}-{host}")),
None => Some(release),
}
})
.unwrap_or_default();
(version, channel)
}
fn build_os() -> String {
let os = if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
std::env::consts::OS
};
format!("{os}-{}", std::env::consts::ARCH)
}
fn build_target() -> String {
let vendor = if cfg!(target_vendor = "apple") {
"apple"
} else if cfg!(target_vendor = "pc") {
"pc"
} else {
"unknown"
};
let env = if cfg!(target_env = "msvc") {
"msvc"
} else if cfg!(target_env = "gnu") {
"gnu"
} else if cfg!(target_env = "musl") {
"musl"
} else {
""
};
let suffix = if env.is_empty() {
String::new()
} else {
format!("-{env}")
};
format!(
"{}-{}-{}{}",
std::env::consts::ARCH,
vendor,
std::env::consts::OS,
suffix
)
}
/// Build time: the modification time of the running executable (the moment
/// cargo wrote it), formatted like the Nushell build_time row.
fn build_time() -> String {
let modified = std::env::current_exe()
.ok()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok());
modified
.map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.format("%Y-%m-%d %H:%M:%S +00:00").to_string()
})
.unwrap_or_default()
}
/// Render the version table (two columns, boxed, no header row), colored
/// with the active theme: keys in the key style, values in the value style.
pub fn version_table(info: &VersionInfo, theme: &Theme, color: bool) -> String {
let rows: Vec<(String, String)> = vec![
("version".to_string(), info.version.clone()),
("major".to_string(), info.major.to_string()),
("minor".to_string(), info.minor.to_string()),
("patch".to_string(), info.patch.to_string()),
("branch".to_string(), info.branch.clone()),
("commit_hash".to_string(), info.commit_hash.clone()),
("build_os".to_string(), info.build_os.clone()),
("build_target".to_string(), info.build_target.clone()),
("rust_version".to_string(), info.rust_version.clone()),
("rust_channel".to_string(), info.rust_channel.clone()),
("cargo_version".to_string(), info.cargo_version.clone()),
("build_time".to_string(), info.build_time.clone()),
("build_rust_channel".to_string(), info.build_rust_channel.clone()),
("allocator".to_string(), info.allocator.clone()),
("theme".to_string(), theme.name.to_string()),
];
crate::output::kv_table(&rows, theme, color)
}
/// Print the version table.
pub fn print_version(app: &App) {
print!("{}", version_table(&version_info(), app.theme(), app.color()));
}
// ---------------------------------------------------------------------------
// -h/--help interception (runs before clap parses the command line)
// ---------------------------------------------------------------------------
/// Intercept Nushell-style help requests on the raw command line:
///
/// * 'am -h' / 'am --help' → the general help
/// * 'am <command> -h|--help' (also 'am ls -h' for shell commands) → the
/// command help; flags after '--' are never intercepted, so
/// 'am run <agent> -- --help' still passes --help through to the agent.
///
/// Returns Some(exit code) when a help request was handled, None when clap
/// should parse the command line normally.
pub fn intercept(args: &[String]) -> Option<i32> {
if args.len() < 2 {
return None;
}
let color = !args.iter().any(|a| a == "--no-color") && std::io::stdout().is_terminal();
let theme = crate::theme::default_theme();
let mut topic: Option<&'static HelpSpec> = None;
for token in args.iter().skip(1) {
let t = token.as_str();
if t == "--" {
break;
}
if t == "-h" || t == "--help" {
match topic {
None => {
print!("{}", render_general(theme, color));
return Some(0);
}
Some(spec) => {
print!("{}", render_command(spec, theme, color));
return Some(0);
}
}
}
if topic.is_none() {
topic = find_spec(t);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn t() -> &'static Theme {
crate::theme::default_theme()
}
fn cli_args(v: &[&str]) -> Vec<String> {
std::iter::once("am".to_string())
.chain(v.iter().map(|s| s.to_string()))
.collect()
}
#[test]
fn renders_ls_help_sections() {
let text = render_command(find_spec("ls").unwrap(), t(), false);
for part in [
"List the filenames, sizes, and modification times",
"Search terms:",
"Usage:",
"> ls {flags} [path]",
"Flags:",
"-h, --help",
"Command Type:",
"built-in",
"Parameters:",
"Input/output types:",
"Examples:",
"> ls",
] {
assert!(text.contains(part), "missing {part:?} in:\n{text}");
}
}
#[test]
fn renders_install_help_flags() {
let text = render_command(find_spec("install").unwrap(), t(), false);
assert!(text.contains("--force"));
assert!(text.contains("--method"));
assert!(text.contains("<agent>"));
assert!(text.contains("Global Flags:"));
}
#[test]
fn renders_config_subcommands() {
let text = render_command(find_spec("config").unwrap(), t(), false);
assert!(text.contains("Subcommands:"));
assert!(text.contains("validate"));
}
#[test]
fn intercepts_help_flags() {
assert!(intercept(&cli_args(&["ls", "--help"])).is_some());
assert!(intercept(&cli_args(&["-h"])).is_some());
assert!(intercept(&cli_args(&["--help"])).is_some());
assert!(intercept(&cli_args(&["install", "-h"])).is_some());
assert!(intercept(&cli_args(&["list", "-v", "--help"])).is_some());
assert!(intercept(&cli_args(&["run", "--help"])).is_some());
assert!(intercept(&cli_args(&["run", "claude-code", "--help"])).is_some());
// No help flag at all: clap parses normally.
assert!(intercept(&cli_args(&["list"])).is_none());
assert!(intercept(&cli_args(&["install", "claude-code"])).is_none());
// -- stops flag parsing.
assert!(intercept(&cli_args(&["list", "--", "-h"])).is_none());
assert!(intercept(&cli_args(&[])).is_none());
}
#[test]
fn general_help_has_tips() {
let text = render_general(t(), false);
assert!(text.contains("Welcome to Agent Manager."));
assert!(text.contains("help commands"));
assert!(text.contains("help --find"));
assert!(text.contains("[Examples]"));
assert!(text.contains("> am install claude-code"));
}
#[test]
fn find_matches() {
let hits = matching_specs("install");
assert!(hits.iter().any(|s| s.name == "install"));
assert!(hits.iter().any(|s| s.name == "uninstall"));
let hits = matching_specs("processes");
assert!(hits.iter().any(|s| s.name == "ps"));
}
#[test]
fn every_spec_has_content() {
for s in HELP_SPECS {
assert!(!s.about.is_empty(), "{}: empty about", s.name);
assert!(!s.usage.is_empty(), "{}: empty usage", s.name);
assert!(!s.examples.is_empty(), "{}: no examples", s.name);
}
}
#[test]
fn version_table_is_aligned() {
let info = version_info();
let text = version_table(&info, t(), false);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(
widths.iter().all(|w| *w == widths[0]),
"unaligned:\n{text}"
);
assert!(text.contains("version"));
assert!(text.contains("build_target"));
assert!(text.contains("allocator"));
assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
assert_eq!(info.major, 0);
assert_eq!(info.minor, 2);
}
#[test]
fn build_target_looks_like_a_triple() {
let target = build_target();
assert_eq!(target.split('-').count(), 4, "{target}");
assert!(target.contains(std::env::consts::ARCH), "{target}");
}
}