From 14f873bfddd48a0354bcbf83724dca9c306b36b6 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 12:52:12 -0400 Subject: [PATCH] v0.2.14 : aide Nushell, themes de couleurs et cadres blancs - Aide facon Nushell : help , -h/--help, help, help commands, help --find , help (fiche info) - sections Usage/Flags/ Command Type/Parameters/Input-output/Examples avec couleurs - Commande version : tableau Nushell (commit, build, rustc, theme...) + --json ; build.rs pour commit/branche git ; -V reste la version courte - Themes : --theme (default, ocean, sunset, forest, dracula, mono) + settings.theme dans la config ; activation VT Windows pour les couleurs - Cadres blancs Nushell partages : list, search, status, doctor, ls/ps/ where/get et menu d autocompletion Tab encadre (colonnes name/description) --- Cargo.lock | 2 +- Cargo.toml | 2 +- build.rs | 32 + src/app.rs | 28 +- src/cli.rs | 17 + src/commands/doctor_cmd.rs | 23 +- src/commands/help_cmd.rs | 12 + src/commands/info_cmd.rs | 2 +- src/commands/list_cmd.rs | 19 +- src/commands/mod.rs | 4 + src/commands/search_cmd.rs | 16 +- src/commands/status_cmd.rs | 59 +- src/commands/version_cmd.rs | 15 + src/config.rs | 4 + src/help.rs | 1364 +++++++++++++++++++++++++++++++++++ src/lib.rs | 13 + src/output.rs | 242 ++++++- src/repl.rs | 265 +++++-- src/tables.rs | 47 +- src/theme.rs | 231 ++++++ 20 files changed, 2272 insertions(+), 125 deletions(-) create mode 100644 build.rs create mode 100644 src/commands/help_cmd.rs create mode 100644 src/commands/version_cmd.rs create mode 100644 src/help.rs create mode 100644 src/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 6e5fb80..076a9f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.13" +version = "0.2.14" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0b36b81..0063569 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.13" +version = "0.2.14" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9496c02 --- /dev/null +++ b/build.rs @@ -0,0 +1,32 @@ +//! Record build metadata (git commit and branch) at compile time so the +//! 'am version' command can display it, like Nushell's version table. +//! +//! When git is unavailable (e.g. building from a source tarball) the values +//! are simply empty. + +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +fn main() { + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/heads"); + let commit = git(&["rev-parse", "HEAD"]); + let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]); + // A detached HEAD reports the literal branch name "HEAD". + let branch = if branch == "HEAD" { + String::new() + } else { + branch + }; + println!("cargo:rustc-env=AM_GIT_COMMIT={commit}"); + println!("cargo:rustc-env=AM_GIT_BRANCH={branch}"); +} diff --git a/src/app.rs b/src/app.rs index 99f706e..142cd4c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,8 @@ use crate::cli::Cli; use crate::config::{self, Config, Settings}; use crate::output::Logger; use crate::state::StateStore; -use anyhow::{Context, Result}; +use crate::theme::{self, Theme}; +use anyhow::{anyhow, Context, Result}; use std::io::{IsTerminal, Write}; use std::path::PathBuf; @@ -19,6 +20,8 @@ pub struct App { pub catalog: Catalog, pub state: StateStore, pub log: Logger, + /// Active output color theme. + pub theme: &'static Theme, } #[derive(Debug, Clone)] @@ -78,6 +81,7 @@ impl App { loaded.config.aliases.clone(), loaded.config.groups.clone(), ); + let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?; Ok(Self { cli, config: loaded.config, @@ -86,6 +90,7 @@ impl App { catalog, state, log, + theme, }) } @@ -105,6 +110,10 @@ impl App { !self.cli.no_color && std::io::stdout().is_terminal() } + pub fn theme(&self) -> &'static Theme { + self.theme + } + /// Ask the user for confirmation on stderr. Accepts y/yes/o/oui. pub fn confirm(&self, question: &str) -> Result { if self.cli.yes { @@ -119,6 +128,23 @@ impl App { } } +/// Resolve the output color theme: the --theme flag wins, then the +/// configuration entry, then the default theme. An unknown --theme value is +/// an error; an unknown configured value silently falls back to the default. +fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> { + if let Some(name) = flag { + return theme::find(name).ok_or_else(|| { + anyhow!( + "unknown theme '{name}' — available: {}", + theme::names().join(", ") + ) + }); + } + Ok(configured + .and_then(theme::find) + .unwrap_or_else(theme::default_theme)) +} + /// Root directory for agent data (overridable with AGENT_MANAGER_DATA, /// mostly used by tests). pub fn data_dir() -> PathBuf { diff --git a/src/cli.rs b/src/cli.rs index 7ca9fb5..93bb72c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,6 +10,9 @@ use std::path::PathBuf; name = "am", bin_name = "am", version, + // The help subcommand is provided by the custom Nushell-style help + // (cli::Command::Help) instead of clap's generated one. + disable_help_subcommand = true, about = "agent-manager (am) — manage local AI coding agents", long_about = "agent-manager (am) — a CLI to manage local AI coding agents. @@ -47,6 +50,10 @@ pub struct Cli { #[arg(long, global = true, action = ArgAction::SetTrue)] pub no_color: bool, + /// Color theme for the output: default, ocean, sunset, forest, dracula, mono + #[arg(long, global = true, value_name = "THEME")] + pub theme: Option, + #[command(subcommand)] pub command: Option, } @@ -131,6 +138,16 @@ pub enum Command { /// Agent name or alias agent: String, }, + /// Display help: the overview, a command, the command list, or a search + Help { + /// Command name (also accepts an agent name or alias); "commands" lists every command + command: Option, + /// Search through all help commands table + #[arg(long, value_name = "TEXT")] + find: Option, + }, + /// Display version and build information + Version, /// Manage the configuration file #[command(subcommand)] Config(ConfigCmd), diff --git a/src/commands/doctor_cmd.rs b/src/commands/doctor_cmd.rs index 42c68df..f50adc7 100644 --- a/src/commands/doctor_cmd.rs +++ b/src/commands/doctor_cmd.rs @@ -2,7 +2,7 @@ use super::*; use crate::doctor::{self, Status}; -use crate::output::{print_json, status_style, Table}; +use crate::output::{boxed_table, print_json, status_style}; use crate::runner::SystemRunner; use anyhow::Result; @@ -27,16 +27,25 @@ pub fn run(app: &App, fix: bool) -> Result { return Ok(if report.ok() { 0 } else { 1 }); } let color = app.color(); - let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]); + let theme = app.theme(); + let mut table_rows = Vec::new(); for c in &report.checks { let status = match c.status { - Status::Ok => status_style("ok", color), - Status::Warn => status_style("warn", color), - Status::Error => status_style("error", color), + Status::Ok => status_style("ok", theme, color), + Status::Warn => status_style("warn", theme, color), + Status::Error => status_style("error", theme, color), }; - table.row(vec![c.name.clone(), status, c.detail.clone()]); + table_rows.push(vec![c.name.clone(), status, c.detail.clone()]); } - print!("{}", table.render()); + print!( + "{}", + boxed_table( + vec!["CHECK", "STATUS", "DETAIL"], + table_rows, + theme, + color, + ) + ); if report.problems() > 0 { app.log .warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems())); diff --git a/src/commands/help_cmd.rs b/src/commands/help_cmd.rs new file mode 100644 index 0000000..a94ba24 --- /dev/null +++ b/src/commands/help_cmd.rs @@ -0,0 +1,12 @@ +//! help: Nushell-style help — the overview, one command, the command list, +//! or a keyword search through it. + +use super::*; + +pub fn run(app: &App, command: Option<&str>, find: Option<&str>) -> Result { + if let Some(text) = find { + crate::help::print_find(app, text); + return Ok(0); + } + crate::help::print_topic(app, command) +} diff --git a/src/commands/info_cmd.rs b/src/commands/info_cmd.rs index 8a5d5c6..f604729 100644 --- a/src/commands/info_cmd.rs +++ b/src/commands/info_cmd.rs @@ -154,7 +154,7 @@ pub fn run(app: &App, name: &str) -> Result { } else { ("not-installed", String::new()) }; - let word = crate::output::status_style(status_word, color); + let word = crate::output::status_style(status_word, app.theme(), color); println!(" {} {}", dim("● status"), format!("{word}{detail}")); // Version: recorded for managed installs, detected for external ones. diff --git a/src/commands/list_cmd.rs b/src/commands/list_cmd.rs index 0a7f59d..63880f3 100644 --- a/src/commands/list_cmd.rs +++ b/src/commands/list_cmd.rs @@ -2,7 +2,7 @@ //! the whole catalog with --all. use super::*; -use crate::output::{print_json, status_style, Table}; +use crate::output::{boxed_table, print_json, status_style}; use crate::probe::ExternalInfo; use crate::state::InstalledEntry; use serde::Serialize; @@ -50,18 +50,27 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result { return Ok(0); } let color = app.color(); - let mut table = Table::new(vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"]); + let theme = app.theme(); + let mut table_rows = Vec::new(); for r in &rows { - table.row(vec![ + table_rows.push(vec![ r.display_name.clone(), r.name.clone(), r.version.clone().unwrap_or_else(|| "—".to_string()), - status_style(&r.status, color), + status_style(&r.status, theme, color), r.source.clone(), r.path.clone().unwrap_or_else(|| "—".to_string()), ]); } - print!("{}", table.render()); + print!( + "{}", + boxed_table( + vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"], + table_rows, + theme, + color, + ) + ); if rows.is_empty() { app.log.info(if all { "no agents found" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 215c1e9..84e072d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod completion_cmd; pub mod config_cmd; pub mod doctor_cmd; pub mod export_import; +pub mod help_cmd; pub mod info_cmd; pub mod install_cmd; pub mod list_cmd; @@ -14,6 +15,7 @@ pub mod self_update; pub mod status_cmd; pub mod uninstall_cmd; pub mod update_cmd; +pub mod version_cmd; use crate::app::App; use crate::cli::Command; @@ -55,6 +57,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all), Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()), Command::Info { agent } => info_cmd::run(app, agent), + Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()), + Command::Version => version_cmd::run(app), Command::Config(sub) => config_cmd::run(app, sub), Command::Doctor { fix } => doctor_cmd::run(app, *fix), Command::Run { agent, args } => run_cmd::run(app, agent, args), diff --git a/src/commands/search_cmd.rs b/src/commands/search_cmd.rs index af18b9b..f748aba 100644 --- a/src/commands/search_cmd.rs +++ b/src/commands/search_cmd.rs @@ -1,7 +1,7 @@ //! search: keyword search over the catalog. use super::*; -use crate::output::{print_json, Table}; +use crate::output::{boxed_table, print_json}; use serde::Serialize; #[derive(Serialize)] @@ -33,17 +33,25 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result { print_json(&rows); return Ok(0); } - let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]); + let mut table_rows = Vec::new(); for a in hits { let desc = truncate(a.description.as_deref().unwrap_or(""), 56); - table.row(vec![ + table_rows.push(vec![ a.title().to_string(), a.category.clone().unwrap_or_else(|| "—".to_string()), if a.installable { "yes" } else { "no" }.to_string(), desc, ]); } - print!("{}", table.render()); + print!( + "{}", + boxed_table( + vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"], + table_rows, + app.theme(), + app.color(), + ) + ); if app.catalog.search(keyword, category).is_empty() { app.log .info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", "))); diff --git a/src/commands/status_cmd.rs b/src/commands/status_cmd.rs index c8da559..63ce858 100644 --- a/src/commands/status_cmd.rs +++ b/src/commands/status_cmd.rs @@ -1,7 +1,7 @@ //! status: detailed state of one agent, or of every installed agent. use super::*; -use crate::output::{print_json, status_style, Table}; +use crate::output::{boxed_table, kv_table, print_json, status_style}; use crate::probe::ExternalInfo; use crate::state::InstalledEntry; use serde::Serialize; @@ -39,28 +39,42 @@ fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result { return Ok(0); } let color = app.color(); + let theme = app.theme(); println!("{} ({})", agent.title(), agent.name); - let status_line = status_style(&detail.status, color); - println!(" status: {status_line}"); - println!( - " version: {}", - detail.version.as_deref().unwrap_or("unknown") - ); - println!(" method: {}", detail.method.as_deref().unwrap_or("—")); - println!(" path: {}", detail.path.as_deref().unwrap_or("—")); - println!(" run: {}", detail.run.as_deref().unwrap_or("—")); + let status_line = status_style(&detail.status, theme, color); + let mut rows: Vec<(String, String)> = vec![ + ("agent".to_string(), agent.name.clone()), + ("status".to_string(), status_line), + ( + "version".to_string(), + detail.version.clone().unwrap_or_else(|| "unknown".to_string()), + ), + ( + "method".to_string(), + detail.method.clone().unwrap_or_else(|| "—".to_string()), + ), + ( + "path".to_string(), + detail.path.clone().unwrap_or_else(|| "—".to_string()), + ), + ( + "run".to_string(), + detail.run.clone().unwrap_or_else(|| "—".to_string()), + ), + ]; if let Some(pid) = detail.pid { - println!(" pid: {pid}"); + rows.push(("pid".to_string(), pid.to_string())); } if let Some(t) = &detail.started_at { - println!(" started: {t}"); + rows.push(("started".to_string(), t.clone())); } if let Some(t) = &detail.installed_at { - println!(" installed: {t}"); + rows.push(("installed".to_string(), t.clone())); } if let Some(l) = &detail.log { - println!(" log: {l}"); + rows.push(("log".to_string(), l.clone())); } + print!("{}", kv_table(&rows, theme, color)); Ok(0) } @@ -90,17 +104,26 @@ fn status_all(app: &App, probe: &ExternalInfo) -> Result { return Ok(0); } let color = app.color(); - let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]); + let theme = app.theme(); + let mut table_rows = Vec::new(); for r in &rows { - table.row(vec![ + table_rows.push(vec![ r.display_name.clone(), r.version.clone().unwrap_or_else(|| "—".to_string()), - status_style(&r.status, color), + status_style(&r.status, theme, color), r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()), r.path.clone().unwrap_or_else(|| "—".to_string()), ]); } - print!("{}", table.render()); + print!( + "{}", + boxed_table( + vec!["NAME", "VERSION", "STATUS", "PID", "PATH"], + table_rows, + theme, + color, + ) + ); if rows.is_empty() { app.log.info("no installed or running agents"); } diff --git a/src/commands/version_cmd.rs b/src/commands/version_cmd.rs new file mode 100644 index 0000000..e955b07 --- /dev/null +++ b/src/commands/version_cmd.rs @@ -0,0 +1,15 @@ +//! version: version and build information, Nushell-style. + +use super::*; +use crate::help::{version_info, version_table}; +use crate::output::print_json; + +pub fn run(app: &App) -> Result { + let info = version_info(); + if app.json() { + print_json(&info); + return Ok(0); + } + print!("{}", version_table(&info, app.theme(), app.color())); + Ok(0) +} diff --git a/src/config.rs b/src/config.rs index 2117af4..21a9ab8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,6 +66,10 @@ pub struct Settings { /// GitHub repository (owner/repo) used by the self-update command. #[serde(default)] pub self_update_repo: Option, + /// Color theme for the output: default, ocean, sunset, forest, dracula, + /// mono (the --theme flag overrides it). + #[serde(default)] + pub theme: Option, } impl Settings { diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..8e73b5f --- /dev/null +++ b/src/help.rs @@ -0,0 +1,1364 @@ +//! Nushell-style help: every command documented as a spec, rendered like +//! 'nu help ' (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} ", + 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} ", + 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} ", + 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} ", + 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} ", + 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:" }, + ], + 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} ", + 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:" }, + ], + 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} ", + 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:" }, + ], + 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} ...(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} ", + 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 ", "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} ", + about: "Generate a shell completion script (bash, zsh, fish, powershell, elvish).", + search_terms: &["autocomplete", "shell"], + flags: &[], + subcommands: &[], + parameters: &[ + HelpParam { name: "shell", typ: "oneof", 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} ", + 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: "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} ", + 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} ", + 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 = 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); + dim(&line, theme, color) + }; + let row = |cells: &[&str], bold: bool| -> String { + let mut line = String::from("│"); + 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('│'); + } + 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 for commands, aliases and +/// modules" behavior). +pub fn print_command(app: &App, name: &str) -> Result { + 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 '" + ) +} + +// --------------------------------------------------------------------------- +// 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 - display help about a particular command or agent\n")); + out.push_str(&format!(" {bullet} help --find - search through all help commands table\n")); + out.push_str(&format!( + " {bullet} am --theme - 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 { + 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 ': 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 { + 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 { + 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 = 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 -h|--help' (also 'am ls -h' for shell commands) → the +/// command help; flags after '--' are never intercepted, so +/// 'am run -- --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 { + 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 { + 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("")); + 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 = 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}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4b6f5a2..ab39337 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod config; pub mod deps; pub mod doctor; pub mod download; +pub mod help; pub mod installers; pub mod nav; pub mod output; @@ -30,6 +31,7 @@ pub mod runner; pub mod shell; pub mod state; pub mod tables; +pub mod theme; pub mod toolchain; pub mod version; @@ -37,6 +39,17 @@ use clap::Parser; /// CLI entry point used by the am binary. Returns the process exit code. pub fn main_entry() -> i32 { + // Enable ANSI escape sequences on Windows consoles (best effort): the + // colored crate and the theme palettes both emit raw VT codes. + #[cfg(windows)] + let _ = colored::control::set_virtual_terminal(true); + + // Nushell-style help: 'am -h', 'am install -h', 'am ls --help', ... all + // show the custom help before clap gets a chance to parse the line. + let args: Vec = std::env::args().collect(); + if let Some(code) = help::intercept(&args) { + return code; + } let cli = cli::Cli::parse(); match app::App::from_cli(cli) { Ok(app) => match commands::execute(&app) { diff --git a/src/output.rs b/src/output.rs index c4cb26c..95e23df 100644 --- a/src/output.rs +++ b/src/output.rs @@ -143,6 +143,148 @@ impl Logger { } } +// --------------------------------------------------------------------------- +// Shared boxed outputs (the Nushell-style "white frame") +// --------------------------------------------------------------------------- + +/// Truncate a string to max visible chars with an ellipsis. +pub 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 + } +} + +/// Build a boxed Nushell-style table (white frame, like ls) from plain rows +/// and render it, fitted to the terminal width and the active theme. +pub fn boxed_table( + headers: Vec<&str>, + rows: Vec>, + theme: &crate::theme::Theme, + color: bool, +) -> String { + use crate::tables::{Cell, DataTable}; + let table = DataTable { + columns: headers.into_iter().map(|h| h.to_string()).collect(), + rows: rows + .into_iter() + .map(|r| r.into_iter().map(Cell::str).collect()) + .collect(), + }; + table.render_themed(theme, color, terminal_width().unwrap_or(120)) +} + +/// Render a two-column key/value box (no header row), like the version +/// table. Keys use the theme key style, values the value style. +pub fn kv_table( + rows: &[(String, String)], + theme: &crate::theme::Theme, + color: bool, +) -> String { + let key_w = rows + .iter() + .map(|(k, _)| visible_len(k)) + .max() + .unwrap_or(0); + let val_w = rows + .iter() + .map(|(_, v)| visible_len(v)) + .max() + .unwrap_or(0); + let border = |left: char, mid: char, right: char| -> String { + let line = format!( + "{left}{}{mid}{}{right}", + "─".repeat(key_w + 2), + "─".repeat(val_w + 2) + ); + if color { + theme.dimmed(&line) + } else { + line + } + }; + let paint = |s: &str, color: bool, style: fn(&crate::theme::Theme, &str) -> String| { + if color && !s.contains('\u{1b}') { + style(theme, s) + } else { + s.to_string() + } + }; + let mut out = String::new(); + out.push_str(&border('╭', '┬', '╮')); + out.push('\n'); + for (k, v) in rows { + let key = paint(k, color, crate::theme::Theme::key); + let val = paint(v, color, crate::theme::Theme::val); + out.push_str(&format!( + "│ {} │ {} │\n", + pad(key.as_str(), key_w), + pad(val.as_str(), val_w) + )); + } + out.push_str(&border('╰', '┴', '╯')); + out.push('\n'); + out +} + +/// Build the lines of the boxed Tab-completion menu from (candidate, +/// description) rows. Single column when no descriptions exist. +pub fn completion_box(rows: &[(String, String)]) -> Vec { + let has_desc = rows.iter().any(|(_, d)| !d.is_empty()); + let name_w = rows + .iter() + .map(|(n, _)| n.chars().count()) + .max() + .unwrap_or(0) + .max("name".chars().count()); + let desc_w = if has_desc { + rows.iter() + .map(|(_, d)| truncate(d, 40).chars().count()) + .max() + .unwrap_or(0) + .max("description".chars().count()) + } else { + 0 + }; + let border = |left: char, mid: char, right: char| -> String { + let mut line = String::new(); + line.push(left); + line.push_str(&"─".repeat(name_w + 2)); + if has_desc { + line.push(mid); + line.push_str(&"─".repeat(desc_w + 2)); + } + line.push(right); + line + }; + let mut out = Vec::new(); + out.push(border('╭', '┬', '╮')); + if has_desc { + out.push(format!( + "│ {} │ {} │", + pad("name", name_w), + pad("description", desc_w) + )); + out.push(border('├', '┼', '┤')); + } + for (name, desc) in rows { + if has_desc { + out.push(format!( + "│ {} │ {} │", + pad(name, name_w), + pad(&truncate(desc, 40), desc_w) + )); + } else { + out.push(format!("│ {} │", pad(name, name_w))); + } + } + out.push(border('╰', '┴', '╯')); + out +} + // --------------------------------------------------------------------------- // Tables // --------------------------------------------------------------------------- @@ -213,22 +355,21 @@ impl Table { } } -/// Color a status cell for display in tables. -pub fn status_style(status: &str, color: bool) -> String { - let s = status.to_string(); +/// Color a status cell for display in tables (theme-aware). +pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String { if !color { - return s; + return status.to_string(); } match status { - "running" => s.green().bold().to_string(), - "installed" => s.cyan().to_string(), - "external" => s.yellow().to_string(), - "not-installable" => s.red().to_string(), - "not-installed" => s.dimmed().to_string(), - "ok" => s.green().to_string(), - "warn" => s.yellow().to_string(), - "error" => s.red().bold().to_string(), - _ => s, + "running" => theme.acc(status), + "installed" => theme.inf(status), + "external" => theme.warn(status), + "not-installable" => theme.err(status), + "not-installed" => theme.dimmed(status), + "ok" => theme.ok(status), + "warn" => theme.warn(status), + "error" => theme.err(status), + _ => status.to_string(), } } @@ -307,3 +448,78 @@ fn pad(s: &str, width: usize) -> String { format!("{s}{}", " ".repeat(width - n)) } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_box_with_descriptions_is_aligned() { + let rows = vec![ + ("list".to_string(), "list installed agents".to_string()), + ("status".to_string(), "show agent state".to_string()), + ]; + let lines = completion_box(&rows); + assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom + assert!(lines[0].starts_with('╭') && lines[0].contains('┬')); + assert!(lines[0].ends_with('╮')); + assert!(lines[1].contains("name") && lines[1].contains("description")); + assert!(lines[2].starts_with('├')); + assert!(lines[5].starts_with('╰')); + let widths: Vec = lines.iter().map(|l| l.chars().count()).collect(); + assert!( + widths.iter().all(|w| *w == widths[0]), + "unaligned: {lines:?}" + ); + assert!(lines[3].contains("list") && lines[3].contains("list installed agents")); + } + + #[test] + fn completion_box_without_descriptions_is_single_column() { + let rows = vec![ + ("src".to_string(), "".to_string()), + ("am.exe".to_string(), "".to_string()), + ]; + let lines = completion_box(&rows); + assert_eq!(lines.len(), 4); // top, 2 rows, bottom + assert!(!lines.iter().any(|l| l.contains("description"))); + let widths: Vec = lines.iter().map(|l| l.chars().count()).collect(); + assert!(widths.iter().all(|w| *w == widths[0])); + } + + #[test] + fn kv_table_is_aligned_with_and_without_colors() { + let rows = vec![ + ("version".to_string(), "0.2.13".to_string()), + ("build_os".to_string(), "windows-x86_64".to_string()), + ]; + for color in [false, true] { + let text = kv_table(&rows, crate::theme::default_theme(), color); + // Compare visible widths (ANSI codes must not shift columns). + let widths: Vec = text + .lines() + .map(|l| strip_ansi(l).chars().count()) + .collect(); + assert!( + widths.iter().all(|w| *w == widths[0]), + "unaligned ({color}):\n{text}" + ); + } + // Color output really contains ANSI sequences. + let colored = kv_table(&rows, crate::theme::default_theme(), true); + assert!(colored.contains("\x1b[")); + } + + #[test] + fn boxed_table_has_a_frame() { + let text = boxed_table( + vec!["NAME", "STATUS"], + vec![vec!["claude-code".to_string(), "external".to_string()]], + crate::theme::default_theme(), + false, + ); + assert!(text.contains("╭") && text.contains("╰")); + assert!(text.contains("claude-code")); + assert!(text.contains("NAME")); + } +} + diff --git a/src/repl.rs b/src/repl.rs index 0a74ad6..73af56f 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -13,14 +13,13 @@ //! available. use crate::app::App; -use crate::cli::{Cli, Command, StartArgs}; +use crate::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; @@ -50,6 +49,8 @@ pub struct AmCompleter { /// Short descriptions shown next to candidates in the Tab menu /// (Nushell-style). descriptions: BTreeMap, + /// Active color theme (used by the boxed menu highlight). + theme: &'static crate::theme::Theme, } /// One-line descriptions for the Tab completion menu. @@ -86,7 +87,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ impl AmCompleter { pub fn new(app: &App) -> Self { - Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups) + let mut completer = + Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups); + completer.theme = app.theme(); + completer } pub fn from_catalog( @@ -121,6 +125,7 @@ impl AmCompleter { descriptions.insert(a.name.clone(), truncate_desc(&desc)); } Self { + theme: crate::theme::default_theme(), commands: vec![ "list", "status", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", @@ -239,25 +244,82 @@ impl Completer for AmCompleter { _ctx: &Context<'_>, ) -> rustyline::Result<(usize, Vec)> { 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, + 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(); + }) + .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; @@ -266,7 +328,39 @@ impl Hinter for AmCompleter { } } -impl Highlighter for AmCompleter {} +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.dimmed(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.hdr(candidate)) + } else { + Cow::Owned(format!( + "│{}│{}│{}", + self.theme.acc(cells[1]), + cells[2], + cells[3] + )) + } + } else { + Cow::Borrowed(candidate) + } + } + _ => Cow::Borrowed(candidate), + } + } +} impl Validator for AmCompleter {} impl Helper for AmCompleter {} @@ -682,9 +776,7 @@ pub fn run(app: &App) -> Result { 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!(); + crate::help::print_general(app); return Ok(2); } let mut session = ShellSession::start(&app.config); @@ -786,11 +878,11 @@ fn handle_input( match line { "exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true), "help" | "?" | "/help" => { - print_help(); + crate::help::print_general(app); return Ok(false); } "version" | "/version" => { - println!("am {}", env!("CARGO_PKG_VERSION")); + crate::help::print_version(app); return Ok(false); } _ => {} @@ -803,6 +895,14 @@ fn handle_input( }; match cmd { "shell" => shell_command(session, 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'")), @@ -839,6 +939,35 @@ fn handle_line( 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); + } + // Session directory change: kept in the am process, not a child shell. if matches!(cmd, "cd" | "chdir") { change_dir(rest)?; @@ -999,6 +1128,14 @@ fn handle_line( 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" | "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 { @@ -1027,7 +1164,14 @@ fn is_am_command(word: &str) -> bool { /// Resolve the path argument of ls/dir (defaults to the current directory). fn ls_target(rest: &[String]) -> Result { - Ok(match rest.first() { + // 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(), }) @@ -1037,7 +1181,10 @@ fn ls_target(rest: &[String]) -> Result { /// 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(app.color(), width)); + print!( + "{}", + table.render_themed(app.theme(), app.color(), width) + ); *last = Some(table); } @@ -1057,6 +1204,18 @@ fn eval_pipeline(app: &App, last: &mut Option, line: &str) -> Result< } 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())?); @@ -1157,44 +1316,24 @@ fn print_shell_status(session: &ShellSession) { println!("use 'shell ' 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 search the catalog"); - println!(" info show agent details"); - println!(" install [--method ] install an agent (--force to reinstall)"); - println!(" uninstall [--purge] remove an agent"); - println!(" update | update --all update agents"); - println!(" start [--background] start an agent"); - println!(" stop [--force] stop an agent"); - println!(" restart [--background] restart an agent"); - println!(" run [args...] run an agent command directly"); - println!(" doctor [--fix] check the environment"); - println!(" config show | path | validate | edit"); - println!(" completion 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 restore a backup"); - println!(" shell [list|] show or switch the system shell"); - println!(" ls [path] · dir [path] list files and directories (Nushell-style table)"); - println!(" ps list processes"); - println!(" where filter the last table (ls | where size > 1mb)"); - println!(" get select a column (ls | get name)"); - println!(" cd 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 "); +/// '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)] diff --git a/src/tables.rs b/src/tables.rs index aa7268a..3aebd14 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -58,6 +58,26 @@ impl DataTable { /// truncated with an ellipsis and the trailing columns are dropped if /// that is still not enough — every row stays on a single line. pub fn render(&self, color: bool, max_width: usize) -> String { + self.render_with(None, color, max_width) + } + + /// Render with the colors of a theme (borders dimmed, header row in the + /// theme's header style instead of plain bold). + pub fn render_themed( + &self, + theme: &crate::theme::Theme, + color: bool, + max_width: usize, + ) -> String { + self.render_with(Some(theme), color, max_width) + } + + fn render_with( + &self, + theme: Option<&crate::theme::Theme>, + color: bool, + max_width: usize, + ) -> String { let max_width = max_width.max(20); let headers = self.columns.clone(); if headers.is_empty() { @@ -133,13 +153,16 @@ impl DataTable { line.push(right); line }; - let row_line = |row: &[String], bold: bool| -> String { + let row_line = |row: &[String], header: bool| -> String { let mut line = String::from("│"); for (i, c) in row.iter().enumerate().take(all_widths.len()) { line.push(' '); let cell = fit_cell(c, all_widths[i]); - let rendered = if bold { - cell.bold().to_string() + let rendered = if header { + match theme { + Some(t) if color => t.hdr(&cell), + _ => cell.bold().to_string(), + } } else { cell }; @@ -149,26 +172,28 @@ impl DataTable { } line }; - let dim = |s: &str| -> String { - if color { - s.dimmed().to_string() - } else { - s.to_string() + let edge = |s: &str| -> String { + if !color { + return s.to_string(); + } + match theme { + Some(t) => t.dimmed(s), + None => s.dimmed().to_string(), } }; let mut out = String::new(); - out.push_str(&dim(&border('╭', '┬', '╮'))); + out.push_str(&edge(&border('╭', '┬', '╮'))); out.push('\n'); out.push_str(&row_line(&all_headers, true)); out.push('\n'); - out.push_str(&dim(&border('├', '┼', '┤'))); + out.push_str(&edge(&border('├', '┼', '┤'))); out.push('\n'); for line in &cells { out.push_str(&row_line(line, false)); out.push('\n'); } - out.push_str(&dim(&border('╰', '┴', '╯'))); + out.push_str(&edge(&border('╰', '┴', '╯'))); out.push('\n'); out } diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..a31202b --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,231 @@ +//! Output color themes: named palettes applied to help, tables, the +//! version output and the completion menu. +//! +//! A theme is a set of SGR (Select Graphic Rendition) parameter strings +//! (e.g. "1;38;5;75" = bold + 256-color 75). Painting wraps the text in +//! "\x1b[m...\x1b[0m". The "mono" theme uses empty sequences and is +//! therefore plain text. + +/// One color theme. +#[derive(Debug, Clone, Copy)] +pub struct Theme { + /// Machine name ("default", "ocean", ...). + pub name: &'static str, + /// Human-readable title. + pub title: &'static str, + /// Section headers ("Usage:", table header rows, ...) — bold. + pub header: &'static str, + /// Accent color: prompts, command names, status "running". + pub accent: &'static str, + /// Secondary color: types, values, status "installed". + pub info: &'static str, + /// Faint: borders, search terms, "not-installed". + pub dim: &'static str, + /// Warnings and "external" status. + pub warn: &'static str, + /// Errors and "not-installable". + pub error: &'static str, + /// Success / "ok". + pub ok: &'static str, + /// Version table keys. + pub key: &'static str, + /// Version table values. + pub value: &'static str, +} + +pub static THEMES: &[Theme] = &[ + Theme { + name: "default", + title: "Default (green/cyan)", + header: "1;36", + accent: "1;32", + info: "36", + dim: "2", + warn: "33", + error: "31", + ok: "1;32", + key: "36", + value: "1", + }, + Theme { + name: "ocean", + title: "Ocean (blues)", + header: "1;38;5;75", + accent: "1;38;5;81", + info: "38;5;117", + dim: "2", + warn: "38;5;221", + error: "38;5;203", + ok: "1;38;5;81", + key: "38;5;111", + value: "1", + }, + Theme { + name: "sunset", + title: "Sunset (orange/pink)", + header: "1;38;5;209", + accent: "1;38;5;215", + info: "38;5;213", + dim: "2", + warn: "38;5;221", + error: "38;5;203", + ok: "1;38;5;215", + key: "38;5;213", + value: "1", + }, + Theme { + name: "forest", + title: "Forest (greens)", + header: "1;38;5;71", + accent: "1;38;5;107", + info: "38;5;72", + dim: "2", + warn: "38;5;179", + error: "38;5;167", + ok: "1;38;5;107", + key: "38;5;108", + value: "1", + }, + Theme { + name: "dracula", + title: "Dracula (purple/green)", + header: "1;38;5;141", + accent: "1;38;5;84", + info: "38;5;117", + dim: "2", + warn: "38;5;228", + error: "38;5;203", + ok: "1;38;5;84", + key: "38;5;141", + value: "1", + }, + Theme { + name: "mono", + title: "Mono (no colors)", + header: "1", + accent: "", + info: "", + dim: "", + warn: "", + error: "", + ok: "1", + key: "", + value: "", + }, +]; + +/// The theme used when nothing else is configured. +pub fn default_theme() -> &'static Theme { + &THEMES[0] +} + +/// Look a theme up by name (case-insensitive). +pub fn find(name: &str) -> Option<&'static Theme> { + let lower = name.trim().to_lowercase(); + THEMES.iter().find(|t| t.name.to_lowercase() == lower) +} + +/// Machine names of every theme, for error messages and help. +pub fn names() -> Vec<&'static str> { + THEMES.iter().map(|t| t.name).collect() +} + +/// One line per theme: "name - title", for the help output. +pub fn describe_all() -> Vec { + THEMES + .iter() + .map(|t| format!("{} - {}", t.name, t.title)) + .collect() +} + +impl Theme { + /// Wrap text in this SGR sequence (empty sequence = plain text). + pub fn paint(&self, sgr: &str, text: &str) -> String { + if sgr.is_empty() || text.is_empty() { + text.to_string() + } else { + format!("\x1b[{sgr}m{text}\x1b[0m") + } + } + + pub fn hdr(&self, text: &str) -> String { + self.paint(self.header, text) + } + + pub fn acc(&self, text: &str) -> String { + self.paint(self.accent, text) + } + + pub fn inf(&self, text: &str) -> String { + self.paint(self.info, text) + } + + pub fn dimmed(&self, text: &str) -> String { + self.paint(self.dim, text) + } + + pub fn warn(&self, text: &str) -> String { + self.paint(self.warn, text) + } + + pub fn err(&self, text: &str) -> String { + self.paint(self.error, text) + } + + pub fn ok(&self, text: &str) -> String { + self.paint(self.ok, text) + } + + pub fn key(&self, text: &str) -> String { + self.paint(self.key, text) + } + + pub fn val(&self, text: &str) -> String { + self.paint(self.value, text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_themes_case_insensitively() { + assert_eq!(find("Ocean").unwrap().name, "ocean"); + assert_eq!(find("default").unwrap().name, "default"); + assert_eq!(find("DRACULA").unwrap().name, "dracula"); + assert!(find("blorp").is_none()); + } + + #[test] + fn default_is_first() { + assert_eq!(default_theme().name, "default"); + } + + #[test] + fn mono_theme_paints_nothing() { + let mono = find("mono").unwrap(); + // Bold (structure) survives; colors are gone. + assert_eq!(mono.hdr("x"), "x"); + assert_eq!(mono.acc("x"), "x"); + assert_eq!(mono.inf("x"), "x"); + assert_eq!(mono.dimmed("x"), "x"); + assert_eq!(mono.key("x"), "x"); + assert_eq!(mono.val("x"), "x"); + } + + #[test] + fn paint_wraps_with_ansi() { + let t = default_theme(); + let s = t.acc("ok"); + assert!(s.starts_with("\x1b[1;32m")); + assert!(s.ends_with("\x1b[0m")); + } + + #[test] + fn every_theme_has_a_description() { + for t in THEMES { + assert!(!t.title.is_empty(), "{}: missing title", t.name); + } + } +}