feat: am tip cheat sheet + readable man pages + REPL completion refresh
This commit is contained in:
@@ -362,6 +362,13 @@ pub enum Command {
|
||||
},
|
||||
/// Display version and build information
|
||||
Version,
|
||||
/// Cheat sheet: the most useful commands, their key options, your
|
||||
/// most-used commands and a rotating tip of the day
|
||||
Tip {
|
||||
/// Show a single random tip instead of the whole page
|
||||
#[arg(long)]
|
||||
random: bool,
|
||||
},
|
||||
/// Manage the configuration file
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCmd),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! launched: installed agents (managed + external), aliases and groups.
|
||||
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// The completion pool: installed agents (managed + external), aliases and
|
||||
/// groups when installed is true, the whole catalog otherwise.
|
||||
@@ -10,16 +11,29 @@ pub fn agent_names(app: &App, installed: bool) -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
if installed {
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
let mut launchable: HashSet<String> = HashSet::new();
|
||||
for a in app.catalog.agents() {
|
||||
if app.state.get(&a.name).ok().flatten().is_some()
|
||||
|| probe.path_of(&a.name).is_some()
|
||||
{
|
||||
launchable.insert(a.name.clone());
|
||||
names.push(a.name.clone());
|
||||
}
|
||||
}
|
||||
names.extend(app.config.aliases.keys().cloned());
|
||||
for g in app.config.groups.keys() {
|
||||
names.push(format!("group:{g}"));
|
||||
for (alias, target) in &app.config.aliases {
|
||||
let is_launchable = app
|
||||
.catalog
|
||||
.resolve(alias)
|
||||
.map(|agent| launchable.contains(&agent.name))
|
||||
.unwrap_or_else(|| launchable.contains(target));
|
||||
if is_launchable {
|
||||
names.push(alias.clone());
|
||||
}
|
||||
}
|
||||
for (group, members) in &app.config.groups {
|
||||
if members.iter().any(|m| launchable.contains(m)) {
|
||||
names.push(format!("group:{group}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
names.extend(app.catalog.agents().iter().map(|a| a.name.clone()));
|
||||
|
||||
+338
-3
@@ -1,11 +1,15 @@
|
||||
//! man: generate roff man pages from the clap definition (issue #41).
|
||||
//! 'am man' prints am.1 for the whole CLI; 'am man <command>' prints one
|
||||
//! page; '--output DIR' writes am.1 plus one am-<command>.1 per command.
|
||||
//!
|
||||
//! On a terminal the page is rendered to readable, themed text (there is no
|
||||
//! man viewer on Windows); when stdout is piped the raw roff source is kept
|
||||
//! so 'am man | man -l -' and scripts keep working unchanged.
|
||||
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
use clap::CommandFactory;
|
||||
use std::io::Write;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i32> {
|
||||
@@ -30,7 +34,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
dir.join(format!("am-{name}.1")).display()
|
||||
));
|
||||
}
|
||||
None => man.render(&mut std::io::stdout())?,
|
||||
None => print_page(app, &man)?,
|
||||
}
|
||||
}
|
||||
None => match output {
|
||||
@@ -51,7 +55,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
cmd.get_subcommands().count()
|
||||
));
|
||||
}
|
||||
None => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
|
||||
None => print_page(app, &clap_mangen::Man::new(cmd))?,
|
||||
},
|
||||
}
|
||||
Ok(0)
|
||||
@@ -68,3 +72,334 @@ fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> {
|
||||
file.flush().ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print one man page: raw roff when stdout is piped (scripts, 'man -l -'),
|
||||
/// a readable themed rendering when it is a terminal.
|
||||
fn print_page(app: &App, man: &clap_mangen::Man) -> Result<()> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
man.render(&mut buf)
|
||||
.context("cannot render the man page")?;
|
||||
let roff = String::from_utf8_lossy(&buf);
|
||||
if std::io::stdout().is_terminal() {
|
||||
print!("{}", roff_to_text(&roff, app.theme(), app.color()));
|
||||
} else {
|
||||
std::io::stdout().write_all(roff.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Miniature roff viewer: renders the groff man macro subset emitted by
|
||||
// clap_mangen (.TH .SH .SS .PP .TP .IP .B .I .BR .RB .BI .IB .IR .RI .RS
|
||||
// .RE .nf .fi ...) to plain, themed text.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IND_TERM: &str = " ";
|
||||
const IND_BODY: &str = " ";
|
||||
|
||||
/// Convert the roff source of a man page into readable text, coloured with
|
||||
/// the active theme when color is on.
|
||||
pub fn roff_to_text(roff: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||
let mut out = String::new();
|
||||
let mut level = 0usize; // .RS/.RE nesting
|
||||
let mut in_tp = false; // inside a .TP definition list
|
||||
let mut tp_term: Option<String> = None; // the pending term of a .TP
|
||||
let mut tp_printed = false; // term emitted, further lines are bodies
|
||||
for raw in roff.lines() {
|
||||
let line = raw.trim_end();
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with(".\\\"") {
|
||||
continue;
|
||||
}
|
||||
// Groff conditionals defining \*(Aq — not needed by the renderer.
|
||||
if trimmed.starts_with(".ie ") || trimmed.starts_with(".el ") {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix('.') {
|
||||
let (name, args) = match rest.split_once(char::is_whitespace) {
|
||||
Some((n, a)) => (n, a.trim()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
// Any other macro closes a pending .TP list.
|
||||
if let Some(term) = tp_term.take() {
|
||||
out.push_str(&format!("{IND_TERM}{term}\n"));
|
||||
}
|
||||
in_tp = false;
|
||||
tp_printed = false;
|
||||
match name {
|
||||
"TH" => {
|
||||
let mut parts = args.split_whitespace();
|
||||
let title = parts.next().unwrap_or("am");
|
||||
let sec = parts.next().unwrap_or("1");
|
||||
let meta = parts.collect::<Vec<_>>().join(" ");
|
||||
let meta = meta.trim_matches('"').to_string();
|
||||
let mut line = theme.acc(&format!("{title}({sec})"));
|
||||
if !meta.is_empty() {
|
||||
line.push_str(&format!(" {}", theme.dimmed(&meta)));
|
||||
}
|
||||
out.push_str(&format!("{line}\n\n"));
|
||||
}
|
||||
"SH" => {
|
||||
out.push('\n');
|
||||
let title = conv(args, theme, color);
|
||||
out.push_str(&format!(
|
||||
"{}\n",
|
||||
if color {
|
||||
theme.hdr(&format!("▸ {title}"))
|
||||
} else {
|
||||
format!("▸ {title}")
|
||||
}
|
||||
));
|
||||
}
|
||||
"SS" => {
|
||||
out.push('\n');
|
||||
let title = conv(args, theme, color);
|
||||
out.push_str(&format!(
|
||||
"{}\n",
|
||||
if color {
|
||||
theme.inf(&format!("· {title}"))
|
||||
} else {
|
||||
format!("· {title}")
|
||||
}
|
||||
));
|
||||
}
|
||||
"PP" | "P" | "sp" => out.push('\n'),
|
||||
"br" => out.push('\n'),
|
||||
"TP" => {
|
||||
in_tp = true;
|
||||
tp_term = None;
|
||||
tp_printed = false;
|
||||
}
|
||||
"IP" => {
|
||||
// Indented paragraph, often a bullet (see also lists).
|
||||
in_tp = true;
|
||||
tp_printed = true;
|
||||
out.push_str(&format!("{IND_TERM}{}\n", conv(args, theme, color)));
|
||||
}
|
||||
"B" => out.push_str(&format!("{IND_TERM}{}\n", bold(conv(args, theme, color), color))),
|
||||
"I" => out.push_str(&format!(
|
||||
"{IND_TERM}{}\n",
|
||||
if color {
|
||||
theme.inf(&conv(args, theme, color))
|
||||
} else {
|
||||
conv(args, theme, color)
|
||||
}
|
||||
)),
|
||||
"BR" | "RB" | "BI" | "IB" | "IR" | "RI" => {
|
||||
let mut parts = args.splitn(2, char::is_whitespace);
|
||||
let a = parts.next().unwrap_or("");
|
||||
let b = parts.next().unwrap_or("");
|
||||
let (bold_first, italic_second) = match name {
|
||||
"BR" | "BI" | "IB" | "IR" => (true, matches!(name, "BI" | "IB")),
|
||||
_ => (false, false),
|
||||
};
|
||||
let left = if bold_first {
|
||||
bold(conv(a, theme, color), color)
|
||||
} else {
|
||||
conv(a, theme, color)
|
||||
};
|
||||
let right = if italic_second {
|
||||
if color {
|
||||
theme.inf(&conv(b, theme, color))
|
||||
} else {
|
||||
conv(b, theme, color)
|
||||
}
|
||||
} else {
|
||||
conv(b, theme, color)
|
||||
};
|
||||
out.push_str(&format!("{IND_TERM}{left} {right}\n"));
|
||||
}
|
||||
"RS" => level += 1,
|
||||
"RE" => level = level.saturating_sub(1),
|
||||
// Filling/formatting directives that do not affect this
|
||||
// line-based rendering.
|
||||
"nf" | "fi" | "ad" | "hy" | "in" | "ds" | "EX" | "EE" => {}
|
||||
other => {
|
||||
// Unknown macro: keep its arguments as plain text when
|
||||
// there is something to show.
|
||||
if !args.is_empty() {
|
||||
out.push_str(&format!("{}{}\n", indent(level), conv(args, theme, color)));
|
||||
}
|
||||
let _ = other;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Plain text line: the first line after a .TP is the term, the
|
||||
// following ones its body. The term is emitted as soon as the
|
||||
// first body line arrives, so terms and bodies stay in order.
|
||||
let text = conv(trimmed, theme, color);
|
||||
if in_tp && !tp_printed {
|
||||
if tp_term.is_none() {
|
||||
tp_term = Some(text);
|
||||
} else {
|
||||
if let Some(term) = tp_term.take() {
|
||||
out.push_str(&format!("{IND_TERM}{term}\n"));
|
||||
}
|
||||
out.push_str(&format!("{IND_BODY}{text}\n"));
|
||||
tp_printed = true;
|
||||
}
|
||||
} else if in_tp {
|
||||
out.push_str(&format!("{IND_BODY}{text}\n"));
|
||||
} else {
|
||||
out.push_str(&format!("{}{}\n", indent(level), text));
|
||||
}
|
||||
}
|
||||
if let Some(term) = tp_term.take() {
|
||||
out.push_str(&format!("{IND_TERM}{term}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Indentation for .RS/.RE nesting (plus a small base margin).
|
||||
fn indent(level: usize) -> String {
|
||||
format!("{}{}", " ".repeat(level), " ")
|
||||
}
|
||||
|
||||
/// Bold text: ANSI bold when colors are on, plain otherwise.
|
||||
fn bold(s: String, color: bool) -> String {
|
||||
if color {
|
||||
format!("\x1b[1m{s}\x1b[0m")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert roff inline escapes to text: \\fB/\\fI/\\fR font switches,
|
||||
/// \\*(Aq, \\- (hyphen), \\(bu (bullet), \\& and friends.
|
||||
fn conv(s: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] != '\\' {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(next) = chars.get(i + 1).copied() else {
|
||||
out.push('\\');
|
||||
break;
|
||||
};
|
||||
match next {
|
||||
'f' => {
|
||||
let mode = chars.get(i + 2).copied().unwrap_or('P');
|
||||
if color {
|
||||
match mode {
|
||||
'B' => out.push_str("\x1b[1m"),
|
||||
'I' => out.push_str(&format!("\x1b[{}m", theme.info)),
|
||||
_ => out.push_str("\x1b[0m"),
|
||||
}
|
||||
}
|
||||
i += 3;
|
||||
}
|
||||
'(' => {
|
||||
let a = chars.get(i + 2).copied().unwrap_or(' ');
|
||||
let d = chars.get(i + 3).copied().unwrap_or(' ');
|
||||
let two = format!("{a}{d}");
|
||||
out.push_str(match two.as_str() {
|
||||
"aq" => "'",
|
||||
"bu" => "•",
|
||||
"em" => "—",
|
||||
"lq" => "\u{201c}",
|
||||
"rq" => "\u{201d}",
|
||||
_ => two.as_str(),
|
||||
});
|
||||
i += 4;
|
||||
}
|
||||
'*' => {
|
||||
// \*(xx : the '(' opens the two-character string name.
|
||||
let a = chars.get(i + 3).copied().unwrap_or(' ');
|
||||
let d = chars.get(i + 4).copied().unwrap_or(' ');
|
||||
let name = format!("{a}{d}");
|
||||
out.push_str(match name.as_str() {
|
||||
"Aq" | "Sq" => "'",
|
||||
_ => "",
|
||||
});
|
||||
i += 5;
|
||||
}
|
||||
'-' => {
|
||||
out.push('-');
|
||||
i += 2;
|
||||
}
|
||||
'&' => i += 2,
|
||||
'e' => {
|
||||
out.push('\\');
|
||||
i += 2;
|
||||
}
|
||||
' ' => {
|
||||
out.push(' ');
|
||||
i += 2;
|
||||
}
|
||||
'\\' => {
|
||||
out.push('\\');
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::default_theme;
|
||||
|
||||
fn plain(roff: &str) -> String {
|
||||
roff_to_text(roff, default_theme(), false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_sections_terms_and_bodies() {
|
||||
let text = plain(
|
||||
".TH am 1 \"am 0.4.3\"\n\
|
||||
.SH NAME\n\
|
||||
am \\- agent manager\n\
|
||||
.SH OPTIONS\n\
|
||||
.TP\n\
|
||||
\\fB\\-c\\fR, \\fB\\-\\-config\\fR \\fI<FILE>\\fR\n\
|
||||
Use an alternate configuration file\n",
|
||||
);
|
||||
assert!(text.contains("am(1)"), "{text}");
|
||||
assert!(text.contains("▸ NAME"), "{text}");
|
||||
assert!(text.contains("▸ OPTIONS"), "{text}");
|
||||
assert!(text.contains("am - agent manager"), "{text}");
|
||||
assert!(text.contains("--config"), "{text}");
|
||||
assert!(text.contains("Use an alternate configuration file"), "{text}");
|
||||
assert!(!text.contains("\\fB"), "font escapes must be converted: {text}");
|
||||
assert!(!text.contains(".SH"), "macros must be removed: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_special_characters() {
|
||||
let t = default_theme();
|
||||
assert_eq!(conv("\\*(Aqhello\\*(Aq", t, false), "'hello'");
|
||||
assert_eq!(conv("\\-\\-dry\\-run", t, false), "--dry-run");
|
||||
assert_eq!(conv("\\(bu", t, false), "•");
|
||||
assert_eq!(conv("\\&x", t, false), "x");
|
||||
assert_eq!(conv("a\\e\\eb", t, false), "a\\\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_bold_and_italic_segments_when_enabled() {
|
||||
let t = default_theme();
|
||||
let s = conv("\\fBam\\fR \\fIvalue\\fR", t, true);
|
||||
assert!(s.contains("\x1b[1m"), "{s}");
|
||||
assert!(s.contains(&format!("\x1b[{}m", t.info)), "{s}");
|
||||
assert!(s.contains("\x1b[0m"), "{s}");
|
||||
// Without colors the same text is plain.
|
||||
assert_eq!(conv("\\fBam\\fR \\fIvalue\\fR", t, false), "am value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tp_term_is_flushed_at_the_next_macro() {
|
||||
let text = plain(".TP\n\\fBam\\fR\nbody line\n.PP\nnext paragraph\n");
|
||||
assert!(text.contains(" am\n"), "{text}");
|
||||
assert!(text.contains(" body line"), "{text}");
|
||||
assert!(text.contains("next paragraph"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod stats_cmd;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod timeline_cmd;
|
||||
pub mod tip_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
pub mod update_cmd;
|
||||
pub mod watch_cmd;
|
||||
@@ -161,6 +162,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Init { force } => init_cmd::run(app, *force),
|
||||
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||
Command::Version => version_cmd::run(app),
|
||||
Command::Tip { random } => tip_cmd::run(app, *random),
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! tip: a cheat sheet of the most useful am commands with their key
|
||||
//! options — plus the user's own most-used commands (from the REPL
|
||||
//! history) and a rotating "did you know" tip.
|
||||
//!
|
||||
//! 'am tip' prints the whole page; 'am tip --random' prints a single tip;
|
||||
//! 'am tip --json' emits the same content as structured JSON.
|
||||
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use crate::history::HistoryEntry;
|
||||
use crate::theme::Theme;
|
||||
use serde_json::json;
|
||||
|
||||
/// One cheat-sheet entry: usage line, one-line purpose, key options and a
|
||||
/// concrete example.
|
||||
pub struct TipEntry {
|
||||
pub usage: &'static str,
|
||||
pub about: &'static str,
|
||||
pub options: &'static [(&'static str, &'static str)],
|
||||
pub example: &'static str,
|
||||
}
|
||||
|
||||
/// One section of the cheat sheet (icon + title + entries).
|
||||
pub struct TipSection {
|
||||
pub icon: &'static str,
|
||||
pub title: &'static str,
|
||||
pub entries: &'static [TipEntry],
|
||||
}
|
||||
|
||||
/// The curated cheat sheet, ordered from everyday actions to expert tools.
|
||||
pub static SECTIONS: &[TipSection] = &[
|
||||
TipSection {
|
||||
icon: "🚀",
|
||||
title: "Lancer & piloter",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "start <agent>",
|
||||
about: "démarre un agent (premier plan par défaut)",
|
||||
options: &[
|
||||
("-b, --background", "détaché en arrière-plan, sortie dans le log"),
|
||||
("--args \"...\"", "arguments supplémentaires passés à l'agent"),
|
||||
("--env KEY=VALUE", "variable d'environnement (répétable)"),
|
||||
("--profile <nom>", "profil d'environnement (env + args)"),
|
||||
],
|
||||
example: "start claude-code --background",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "stop <agent>",
|
||||
about: "arrête un agent en arrière-plan (SIGTERM puis SIGKILL)",
|
||||
options: &[
|
||||
("--force", "kill immédiat, sans délai de grâce"),
|
||||
("--timeout <secs>", "délai de grâce avant le kill (défaut : 5 s)"),
|
||||
],
|
||||
example: "stop pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "restart <agent>",
|
||||
about: "redémarre avec les mêmes options (agent, groupe, profil…)",
|
||||
options: &[],
|
||||
example: "restart group:dev",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "watch <agent>",
|
||||
about: "supervise l'agent et le relance s'il meurt",
|
||||
options: &[
|
||||
("--restart", "relance automatique avec backoff"),
|
||||
("--notify", "notification bureau à chaque relance"),
|
||||
],
|
||||
example: "watch smelt --restart",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "run <agent>",
|
||||
about: "exécute la commande de l'agent directement (sans gestion de processus)",
|
||||
options: &[("--args \"...\"", "arguments transmis tels quels à l'agent")],
|
||||
example: "run claude-code --version",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "📦",
|
||||
title: "Installer & maintenir",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "list",
|
||||
about: "agents installés (managed + external détectés sur le PATH)",
|
||||
options: &[
|
||||
("--all", "catalogue complet, y compris non installés"),
|
||||
("--running", "uniquement les agents actifs"),
|
||||
("--sort <key>", "name, version ou status"),
|
||||
("--category <cat>", "filtre par catégorie"),
|
||||
],
|
||||
example: "list --all",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "search <mot>",
|
||||
about: "cherche dans le catalogue (nom, description, catégorie, tags)",
|
||||
options: &[],
|
||||
example: "search pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "install <agent>",
|
||||
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
|
||||
options: &[
|
||||
("--method <m>", "choisit la méthode (index ou type:npm, binary…)"),
|
||||
("--force", "réinstalle même si déjà présent"),
|
||||
],
|
||||
example: "install pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update <agent>",
|
||||
about: "met à jour un agent vers la dernière version disponible",
|
||||
options: &[("--all", "tous les agents installés d'un coup")],
|
||||
example: "update --all",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "uninstall <agent>",
|
||||
about: "supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)",
|
||||
options: &[("--purge", "supprime aussi les logs et la définition de la config")],
|
||||
example: "uninstall pi",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "👁️",
|
||||
title: "Observer",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "status",
|
||||
about: "état de tous les agents (running / installed / external)",
|
||||
options: &[],
|
||||
example: "status",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "logs <agent>",
|
||||
about: "lit la fin du journal de l'agent",
|
||||
options: &[
|
||||
("--follow", "suit le log en direct"),
|
||||
("--lines <N>", "nombre de lignes (défaut : 20)"),
|
||||
],
|
||||
example: "logs pi --follow",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "dashboard",
|
||||
about: "TUI en direct : activité, stats, sessions, projets",
|
||||
options: &[],
|
||||
example: "dashboard",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "sessions · stats · top",
|
||||
about: "historique des sessions · statistiques · agents les plus utilisés",
|
||||
options: &[("--period <p>", "7d, 30d, 90d ou all")],
|
||||
example: "top --period 30d",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "timeline",
|
||||
about: "une vue chronologique de toute l'activité",
|
||||
options: &[("--since <date>", "activité à partir d'une date")],
|
||||
example: "timeline --since 2026-08-01",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "⚙️",
|
||||
title: "Personnaliser",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "alias add <nom> <agent>",
|
||||
about: "crée un raccourci (ex. cc → claude-code)",
|
||||
options: &[],
|
||||
example: "alias add cc claude-code",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "profile",
|
||||
about: "profils d'environnement réutilisables (env + args)",
|
||||
options: &[],
|
||||
example: "start pi --profile dev",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "config show",
|
||||
about: "configuration effective (catalogue fusionné, alias, groupes…)",
|
||||
options: &[],
|
||||
example: "config show",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "theme <nom>",
|
||||
about: "change la palette du shell, des tableaux et de cette page",
|
||||
options: &[],
|
||||
example: "theme ocean",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "secret set <nom> --agent <a> --value <v>",
|
||||
about: "stocke un secret dans le keyring de l'OS, jamais en clair",
|
||||
options: &[],
|
||||
example: "secret set API_KEY --agent pi --value sk-…",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "favorite · note · tag",
|
||||
about: "annotez vos agents : étoile, note libre, tags personnels",
|
||||
options: &[],
|
||||
example: "tag pi perso",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "🧰",
|
||||
title: "Boîte à outils",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "doctor --fix",
|
||||
about: "diagnostique l'environnement et répare les problèmes courants",
|
||||
options: &[],
|
||||
example: "doctor --fix",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "export · import",
|
||||
about: "sauvegarde config + état, puis restauration",
|
||||
options: &[("--output <file>", "fichier de destination (défaut : am-export.json)")],
|
||||
example: "export --output backup.json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "completion <shell> --installed",
|
||||
about: "complétion dynamique : agents installés, alias et groupes au Tab",
|
||||
options: &[],
|
||||
example: "completion powershell --installed",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "man <commande>",
|
||||
about: "page de manuel pour une commande",
|
||||
options: &[],
|
||||
example: "man start",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "help --find <mot>",
|
||||
about: "cherche une commande dans toute l'aide",
|
||||
options: &[],
|
||||
example: "help --find log",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "self-update",
|
||||
about: "met à jour agent-manager lui-même",
|
||||
options: &[("--check", "vérifie seulement si une version existe")],
|
||||
example: "self-update --check",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// Rotating pro tips ("Le saviez-vous ?").
|
||||
const DID_YOU_KNOW: &[(&str, &str)] = &[
|
||||
(
|
||||
"Tab complète vos agents",
|
||||
"dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.",
|
||||
),
|
||||
(
|
||||
"!! relance la dernière commande",
|
||||
"l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.",
|
||||
),
|
||||
(
|
||||
"Le shell am double le shell système",
|
||||
"tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.",
|
||||
),
|
||||
(
|
||||
"--dry-run simule sans rien changer",
|
||||
"testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.",
|
||||
),
|
||||
(
|
||||
"Les groupes lancent plusieurs agents",
|
||||
"'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.",
|
||||
),
|
||||
(
|
||||
"Cherchez dans toute l'aide",
|
||||
"'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.",
|
||||
),
|
||||
(
|
||||
"--json pour les scripts",
|
||||
"quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.",
|
||||
),
|
||||
(
|
||||
"Les alias raccourcissent tout",
|
||||
"'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.",
|
||||
),
|
||||
(
|
||||
"export/import sauvegardent votre monde",
|
||||
"config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.",
|
||||
),
|
||||
(
|
||||
"Les secrets vivent dans le keyring",
|
||||
"'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.",
|
||||
),
|
||||
(
|
||||
"Le thème suit votre humeur",
|
||||
"'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.",
|
||||
),
|
||||
(
|
||||
"doctor répare",
|
||||
"'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).",
|
||||
),
|
||||
];
|
||||
|
||||
/// Entry point: print the cheat sheet page (or a single random tip).
|
||||
pub fn run(app: &App, random: bool) -> Result<i32> {
|
||||
print!("{}", render(app, random));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Render the page as text (or JSON with --json).
|
||||
pub fn render(app: &App, random: bool) -> String {
|
||||
if app.json() {
|
||||
return render_json(app, random);
|
||||
}
|
||||
let p = Painter {
|
||||
theme: app.theme(),
|
||||
color: app.color(),
|
||||
};
|
||||
if random {
|
||||
let (title, body) = random_tip();
|
||||
return format!("💡 {} — {}\n", p.acc(title), body);
|
||||
}
|
||||
let mut out = String::new();
|
||||
out.push_str(&header(app, &p));
|
||||
out.push('\n');
|
||||
for s in SECTIONS {
|
||||
out.push_str(§ion_rule(app, &p, s.icon, s.title));
|
||||
out.push('\n');
|
||||
for e in s.entries {
|
||||
out.push_str(&entry(&p, e));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&most_used_section(app, &p));
|
||||
out.push('\n');
|
||||
let (title, body) = daily_tip();
|
||||
out.push_str(§ion_rule(app, &p, "💡", "Le saviez-vous ?"));
|
||||
out.push('\n');
|
||||
out.push_str(&format!(" {} — {}\n", p.acc(title), body));
|
||||
out.push('\n');
|
||||
out.push_str(&format!(
|
||||
" {}\n",
|
||||
p.dim("am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'")
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Width of the cheat sheet, following the terminal (capped).
|
||||
fn page_width(app: &App) -> usize {
|
||||
crate::output::terminal_width()
|
||||
.unwrap_or(100)
|
||||
.clamp(50, 110)
|
||||
}
|
||||
|
||||
/// Tiny themed painter that honours the global --no-color flag.
|
||||
struct Painter<'a> {
|
||||
theme: &'a Theme,
|
||||
color: bool,
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn paint(&self, f: fn(&Theme, &str) -> String, s: &str) -> String {
|
||||
if self.color {
|
||||
f(self.theme, s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
fn acc(&self, s: &str) -> String {
|
||||
self.paint(Theme::acc, s)
|
||||
}
|
||||
fn inf(&self, s: &str) -> String {
|
||||
self.paint(Theme::inf, s)
|
||||
}
|
||||
fn dim(&self, s: &str) -> String {
|
||||
self.paint(Theme::dimmed, s)
|
||||
}
|
||||
fn hdr(&self, s: &str) -> String {
|
||||
self.paint(Theme::hdr, s)
|
||||
}
|
||||
fn frame(&self, s: &str) -> String {
|
||||
self.paint(Theme::frame, s)
|
||||
}
|
||||
fn val(&self, s: &str) -> String {
|
||||
self.paint(Theme::val, s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Title box: a framed header with a subtitle line.
|
||||
fn header(app: &App, p: &Painter) -> String {
|
||||
let w = page_width(app);
|
||||
let title = " 🧠 TIP · votre antisèche de commandes am ";
|
||||
let pad = w.saturating_sub(title.chars().count() + 2).max(1);
|
||||
let top = format!("╭─{title}{}╮", "─".repeat(pad));
|
||||
let sub = " les commandes les plus utiles · vos habitudes · une astuce du jour";
|
||||
let subpad = w.saturating_sub(sub.chars().count() + 2).max(1);
|
||||
let mid = format!("│{sub}{}│", " ".repeat(subpad));
|
||||
let bot = format!("╰{}╯", "─".repeat(w.saturating_sub(2).max(2)));
|
||||
format!(
|
||||
"{}\n{}\n{}\n",
|
||||
p.frame(&top),
|
||||
p.dim(&mid),
|
||||
p.frame(&bot)
|
||||
)
|
||||
}
|
||||
|
||||
/// Section separator: " 🚀 Lancer & piloter ────────".
|
||||
fn section_rule(app: &App, p: &Painter, icon: &str, title: &str) -> String {
|
||||
let w = page_width(app);
|
||||
let label = format!(" {icon} {title} ");
|
||||
let dashes = "─".repeat(w.saturating_sub(label.chars().count()).max(1));
|
||||
format!("{}{}", p.hdr(&label), p.frame(&dashes))
|
||||
}
|
||||
|
||||
/// One cheat-sheet entry: usage line, options, example.
|
||||
fn entry(p: &Painter, e: &TipEntry) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(" {} {} {}\n", p.inf("▸"), p.acc(e.usage), e.about));
|
||||
for (flag, desc) in e.options {
|
||||
out.push_str(&format!(" {} {}\n", p.dim(flag), desc));
|
||||
}
|
||||
if !e.example.is_empty() {
|
||||
out.push_str(&format!(" {} {}\n", p.dim("❯"), p.val(e.example)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Count the am commands of a history (first token per entry), most used
|
||||
/// first, truncated to 8. Pure and testable.
|
||||
pub fn count_commands(entries: &[HistoryEntry]) -> Vec<(String, usize)> {
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for e in entries {
|
||||
if e.kind != "am" {
|
||||
continue;
|
||||
}
|
||||
if let Some(first) = e.cmd.split_whitespace().next() {
|
||||
if !first.is_empty() {
|
||||
*counts.entry(first.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
v.truncate(8);
|
||||
v
|
||||
}
|
||||
|
||||
/// The user's most-used commands, from the REPL history.
|
||||
fn most_used(app: &App) -> Vec<(String, usize)> {
|
||||
count_commands(&crate::history::read_all(app, 0))
|
||||
}
|
||||
|
||||
/// A 10-cell usage bar: ▰ for used cells, ▱ for the rest.
|
||||
fn usage_bar(count: usize, max: usize) -> String {
|
||||
if max == 0 {
|
||||
return "▱".repeat(10);
|
||||
}
|
||||
let filled = (count * 10 + max / 2) / max;
|
||||
format!(
|
||||
"{}{}",
|
||||
"▰".repeat(filled.min(10)),
|
||||
"▱".repeat(10usize.saturating_sub(filled.min(10)))
|
||||
)
|
||||
}
|
||||
|
||||
/// "🔥 Vos commandes les plus utilisées": a little bar chart from the
|
||||
/// REPL history, so the page grows with the user.
|
||||
fn most_used_section(app: &App, p: &Painter) -> String {
|
||||
let top = most_used(app);
|
||||
let mut out = String::new();
|
||||
out.push_str(§ion_rule(app, p, "🔥", "Vos commandes les plus utilisées"));
|
||||
out.push('\n');
|
||||
if top.is_empty() {
|
||||
out.push_str(&format!(
|
||||
" {} — lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici.\n",
|
||||
p.dim("aucune commande enregistrée")
|
||||
));
|
||||
return out;
|
||||
}
|
||||
let max = top[0].1;
|
||||
let name_w = top.iter().map(|(n, _)| n.chars().count()).max().unwrap_or(0).min(14);
|
||||
for (name, count) in &top {
|
||||
out.push_str(&format!(
|
||||
" {} {} {}×\n",
|
||||
p.acc(&format!("{name:<name_w$}")),
|
||||
p.dim(&usage_bar(*count, max)),
|
||||
p.inf(&count.to_string()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Tip of the day: deterministic pick that changes daily.
|
||||
fn daily_tip() -> (&'static str, &'static str) {
|
||||
let day = chrono::Local::now().ordinal() as usize;
|
||||
DID_YOU_KNOW[day % DID_YOU_KNOW.len()]
|
||||
}
|
||||
|
||||
/// Random tip for '--random'.
|
||||
fn random_tip() -> (&'static str, &'static str) {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as usize)
|
||||
.unwrap_or(0);
|
||||
DID_YOU_KNOW[nanos % DID_YOU_KNOW.len()]
|
||||
}
|
||||
|
||||
/// The same content as structured JSON (--json).
|
||||
fn render_json(app: &App, random: bool) -> String {
|
||||
let (title, body) = if random { random_tip() } else { daily_tip() };
|
||||
let sections: Vec<serde_json::Value> = SECTIONS
|
||||
.iter()
|
||||
.map(|s| {
|
||||
json!({
|
||||
"icon": s.icon,
|
||||
"title": s.title,
|
||||
"entries": s.entries.iter().map(|e| json!({
|
||||
"usage": e.usage,
|
||||
"about": e.about,
|
||||
"options": e.options.iter().map(|(flag, desc)| json!({ "flag": flag, "desc": desc })).collect::<Vec<_>>(),
|
||||
"example": e.example,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({
|
||||
"sections": sections,
|
||||
"most_used": most_used(app).iter().map(|(command, count)| json!({ "command": command, "count": count })).collect::<Vec<_>>(),
|
||||
"tip": { "title": title, "body": body },
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(cmd: &str, kind: &str) -> HistoryEntry {
|
||||
HistoryEntry::now(cmd.to_string()).with_kind(kind.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_only_am_commands_by_first_token() {
|
||||
let entries = vec![
|
||||
entry("start pi", "am"),
|
||||
entry("start pi", "am"),
|
||||
entry("list", "am"),
|
||||
entry("git status", "shell"),
|
||||
entry("start claude-code", "am"),
|
||||
entry("", "am"),
|
||||
];
|
||||
let top = count_commands(&entries);
|
||||
assert_eq!(top[0], ("start".to_string(), 3));
|
||||
assert_eq!(top[1], ("list".to_string(), 1));
|
||||
assert_eq!(top.len(), 2, "shell commands and empty lines are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_yields_no_commands() {
|
||||
assert!(count_commands(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_bars_fill_and_cap_at_ten_cells() {
|
||||
assert_eq!(usage_bar(10, 10), "▰".repeat(10));
|
||||
assert_eq!(usage_bar(0, 10), "▱".repeat(10));
|
||||
assert_eq!(usage_bar(5, 10), "▰".repeat(5) + &"▱".repeat(5));
|
||||
// A count larger than the max still fits the bar.
|
||||
assert_eq!(usage_bar(99, 10).chars().count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_tip_is_always_in_bounds() {
|
||||
let (t, b) = daily_tip();
|
||||
assert!(DID_YOU_KNOW.iter().any(|(x, y)| *x == t && *y == b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_are_non_empty_and_consistent() {
|
||||
for s in SECTIONS {
|
||||
assert!(!s.entries.is_empty(), "section {} has no entries", s.title);
|
||||
for e in s.entries {
|
||||
assert!(!e.usage.is_empty());
|
||||
assert!(!e.about.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-4
@@ -885,8 +885,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "man",
|
||||
category: "Commands",
|
||||
usage: "man {flags} [command]",
|
||||
about: "Generate roff man pages from the command definitions: one page for the whole CLI, or one page per command with --output.",
|
||||
search_terms: &["documentation", "roff", "unix"],
|
||||
about: "Readable man pages in the terminal (the raw roff source is kept when piped): one page for the whole CLI, or one per command with --output.",
|
||||
search_terms: &["documentation", "roff", "unix", "manual"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--output", value: "DIR", desc: "Write am.1 plus one am-<command>.1 per command into this directory" },
|
||||
],
|
||||
@@ -896,9 +896,10 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Print the man page of the whole CLI.", code: "man" },
|
||||
HelpExample { desc: "Print one command page.", code: "man install" },
|
||||
HelpExample { desc: "Read the man page of the whole CLI (rendered in the terminal).", code: "man" },
|
||||
HelpExample { desc: "Read one command page.", code: "man install" },
|
||||
HelpExample { desc: "Write every page into man/ (for a package).", code: "man --output man/" },
|
||||
HelpExample { desc: "Pipe the raw roff source into a real man viewer.", code: "man | man -l -" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -953,6 +954,26 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Switch to the ocean theme (kept for the next start).", code: "theme ocean" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "tip",
|
||||
category: "Commands",
|
||||
usage: "tip {flags}",
|
||||
about: "Cheat sheet : les commandes les plus utiles avec leurs options clés, vos commandes les plus utilisées et une astuce du jour.",
|
||||
search_terms: &["cheat", "sheet", "tips", "astuce", "aide", "raccourci", "usage"],
|
||||
flags: &[HelpFlag {
|
||||
short: "",
|
||||
long: "--random",
|
||||
value: "",
|
||||
desc: "Show a single random tip instead of the whole page",
|
||||
}],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Show the whole cheat sheet.", code: "tip" },
|
||||
HelpExample { desc: "Show a single random tip.", code: "tip --random" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "ls",
|
||||
category: "Shell",
|
||||
|
||||
+115
-12
@@ -33,7 +33,7 @@ use reedline::{
|
||||
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -103,9 +103,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("untag", "remove a personal tag"),
|
||||
("tags", "list personal tags"),
|
||||
("profile", "manage environment profiles"),
|
||||
("man", "generate man pages"),
|
||||
("man", "readable man page in the terminal"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("theme", "show or switch the color theme"),
|
||||
("tip", "cheat sheet of the most useful commands"),
|
||||
("ls", "list files and directories (Nushell-style)"),
|
||||
("dir", "list files and directories (same as ls)"),
|
||||
("cd", "change directory — Tab completes folders"),
|
||||
@@ -143,6 +144,30 @@ impl AmCompleter {
|
||||
}
|
||||
completer.agents.sort();
|
||||
completer.agents.dedup();
|
||||
|
||||
// Issue #41 follow-up: only propose aliases and groups whose target is
|
||||
// actually launchable (installed managed or external on PATH).
|
||||
let launchable: HashSet<String> = completer.agents.iter().cloned().collect();
|
||||
completer
|
||||
.aliases
|
||||
.retain(|alias| match app.catalog.resolve(alias) {
|
||||
Some(agent) => launchable.contains(&agent.name),
|
||||
None => {
|
||||
// Fallback: alias target is not a known agent name, but
|
||||
// points to a launchable binary recorded in the catalog.
|
||||
app.catalog
|
||||
.alias_target(alias)
|
||||
.map(|target| launchable.contains(target))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
});
|
||||
completer.groups.retain(|group| {
|
||||
let name = group.strip_prefix("group:").unwrap_or(group);
|
||||
app.catalog
|
||||
.group_members(name)
|
||||
.iter()
|
||||
.any(|agent| launchable.contains(&agent.name))
|
||||
});
|
||||
completer
|
||||
}
|
||||
|
||||
@@ -191,7 +216,7 @@ impl AmCompleter {
|
||||
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
@@ -206,7 +231,7 @@ impl AmCompleter {
|
||||
"--background", "--force", "--purge", "--method", "--all",
|
||||
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
||||
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
||||
"--profile", "--installed", "--tag", "--output",
|
||||
"--profile", "--installed", "--tag", "--output", "--random",
|
||||
],
|
||||
agents,
|
||||
catalog_agents,
|
||||
@@ -711,23 +736,35 @@ pub fn banner_box(
|
||||
color,
|
||||
)));
|
||||
|
||||
// am command groups.
|
||||
// am command groups: every command the REPL recognizes, grouped by
|
||||
// purpose. Keep this in sync with the completer (COMMAND_DESCRIPTIONS).
|
||||
rows.push(blank());
|
||||
rows.push(inner(if color {
|
||||
theme.hdr("Commands")
|
||||
} else {
|
||||
"Commands".to_string()
|
||||
}));
|
||||
rows.push(inner(" catalog list · status · search · info".to_string()));
|
||||
rows.push(inner(" catalog list · status · search · info · init".to_string()));
|
||||
rows.push(inner(
|
||||
" activity sessions · stats · top · report · projects · timeline · log · logs · history"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" system doctor · config · completion · self-update · self-uninstall · export · import"
|
||||
" manage open · watch · dashboard · favorite · unfavorite · note · tag · untag · tags"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" shell ls · cd · ps · where · get · shell · theme · help · version · exit".to_string(),
|
||||
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" system self-update · self-uninstall · export · import".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" shell ls · dir · cd · ps · where · get · shell · theme · help · version · exit"
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
// Stats footer.
|
||||
@@ -1078,10 +1115,15 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i
|
||||
let result = handle_input(app, session, &mut last, &trimmed);
|
||||
last_duration = Some(t.elapsed());
|
||||
record_history(app, sid, &trimmed, &result, t.elapsed());
|
||||
// 'theme <name>' may have switched the app theme: rebuild the editor
|
||||
// so the completion menu follows the new palette (the history is
|
||||
// file-backed and re-read from disk).
|
||||
if !std::ptr::eq(theme, app.theme()) {
|
||||
// Rebuild the editor when the completion pool or the palette may
|
||||
// have changed: 'theme <name>' switches the menu colors, and the
|
||||
// install/uninstall/update/alias/config/import commands mutate the
|
||||
// installed agents, aliases or groups that Tab proposes (the
|
||||
// completer snapshots them at startup, so a fresh install would
|
||||
// stay hidden until an exit/restart otherwise). The history is
|
||||
// file-backed and re-read from disk.
|
||||
let first_word = trimmed.split_whitespace().next().unwrap_or("");
|
||||
if !std::ptr::eq(theme, app.theme()) || completion_pool_mutating(first_word) {
|
||||
theme = app.theme();
|
||||
let _ = rl.sync_history();
|
||||
match build_line_editor(app, &history_path) {
|
||||
@@ -1568,6 +1610,9 @@ fn handle_line(
|
||||
"import" => Command::Import {
|
||||
file: need("file")?.into(),
|
||||
},
|
||||
"tip" => Command::Tip {
|
||||
random: flag("--random"),
|
||||
},
|
||||
other => {
|
||||
app.log
|
||||
.error(&format!("unknown command '{other}' — type 'help'"));
|
||||
@@ -1589,6 +1634,16 @@ fn is_shell_command(word: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Commands whose execution can change the agent completion pool
|
||||
/// (installed agents, aliases, groups): the Tab completer snapshots that
|
||||
/// pool at startup, so the line editor is rebuilt after one of these runs.
|
||||
fn completion_pool_mutating(first: &str) -> bool {
|
||||
matches!(
|
||||
first,
|
||||
"install" | "uninstall" | "update" | "alias" | "config" | "import" | "self-update"
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a word is one of the am commands (everything else is executed by
|
||||
/// the system shell).
|
||||
fn is_am_command(word: &str) -> bool {
|
||||
@@ -1635,6 +1690,7 @@ fn is_am_command(word: &str) -> bool {
|
||||
| "tags"
|
||||
| "profile"
|
||||
| "man"
|
||||
| "tip"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1990,6 +2046,27 @@ mod tests {
|
||||
assert!(cands.contains(&"--dry-run".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_mutating_commands_are_detected() {
|
||||
// These commands change the agents/aliases/groups that Tab proposes:
|
||||
// the line editor must be rebuilt after them.
|
||||
for cmd in [
|
||||
"install", "uninstall", "update", "alias", "config", "import", "self-update",
|
||||
] {
|
||||
assert!(
|
||||
completion_pool_mutating(cmd),
|
||||
"{cmd} must refresh the completion pool"
|
||||
);
|
||||
}
|
||||
// Read-only and system commands must not trigger a rebuild.
|
||||
for cmd in ["start", "stop", "list", "status", "ls", "cd", "theme", "help", "exit"] {
|
||||
assert!(
|
||||
!completion_pool_mutating(cmd),
|
||||
"{cmd} must not refresh the completion pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_config_subcommands() {
|
||||
let c = completer();
|
||||
@@ -2055,6 +2132,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_commands_section_lists_every_command() {
|
||||
let session = test_session();
|
||||
let text = banner_box(
|
||||
"9.9.9",
|
||||
65,
|
||||
3,
|
||||
12,
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
crate::theme::default_theme(),
|
||||
false,
|
||||
);
|
||||
// The Commands section starts at the "Commands" heading and runs to
|
||||
// the stats footer. Every command the REPL knows (am + shell) must
|
||||
// appear there.
|
||||
let section = text.split_once("Commands").map(|(_, r)| r).unwrap_or_default();
|
||||
for (cmd, _) in COMMAND_DESCRIPTIONS {
|
||||
assert!(
|
||||
section.contains(&format!(" {cmd} ")),
|
||||
"banner Commands section misses {cmd}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_box_is_themed_and_still_aligned() {
|
||||
let session = test_session();
|
||||
|
||||
+81
-40
@@ -53,10 +53,12 @@ impl DataTable {
|
||||
.position(|c| c.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Render the boxed Nushell-style table, fitting max_width columns.
|
||||
/// When the natural layout is too wide, the first (name) column is
|
||||
/// truncated with an ellipsis and the trailing columns are dropped if
|
||||
/// that is still not enough — every row stays on a single line.
|
||||
/// Render the boxed Nushell-style table, sized to its content and capped
|
||||
/// at `max_width` so every row stays on a single line. When the row is
|
||||
/// too wide, the widest column shrinks first (with an ellipsis) — identity
|
||||
/// columns such as `name` stay compact while content columns such as
|
||||
/// `path` keep as much room as possible. Trailing columns are dropped
|
||||
/// only when even header-only columns cannot fit.
|
||||
pub fn render(&self, color: bool, max_width: usize) -> String {
|
||||
self.render_with(None, color, max_width)
|
||||
}
|
||||
@@ -97,44 +99,58 @@ impl DataTable {
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
let line_width = |ncols: usize, widths: &[usize]| -> usize {
|
||||
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
|
||||
};
|
||||
// Frame overhead (borders, separators and the leading "#" index
|
||||
// column) for a given number of data columns.
|
||||
let fixed = |ncols: usize| 1 + 3 * (ncols + 1) + index_width;
|
||||
// A column never shrinks below its header, so headers stay readable.
|
||||
let min_w: Vec<usize> = headers.iter().map(|h| visible_len(h).max(1)).collect();
|
||||
|
||||
// Choose the final column set and widths.
|
||||
// Keep as many columns as possible: drop trailing columns only when
|
||||
// even the minimal (header-only) layout cannot fit.
|
||||
let mut data_cols = headers.len();
|
||||
let mut widths: Vec<usize> = natural.clone();
|
||||
loop {
|
||||
if data_cols == 0 {
|
||||
break;
|
||||
}
|
||||
widths.truncate(data_cols);
|
||||
if line_width(data_cols, &widths) <= max_width {
|
||||
// Expand the first column into the remaining slack.
|
||||
widths[0] += max_width - line_width(data_cols, &widths);
|
||||
break;
|
||||
}
|
||||
// Cap the non-first columns, then give the first what remains.
|
||||
let mut capped = widths.clone();
|
||||
for w in capped.iter_mut().skip(1) {
|
||||
*w = (*w).min(16);
|
||||
}
|
||||
if line_width(data_cols, &capped) <= max_width {
|
||||
capped[0] += max_width - line_width(data_cols, &capped);
|
||||
widths = capped;
|
||||
break;
|
||||
}
|
||||
if data_cols == 1 {
|
||||
// Last resort: shrink the single column to whatever remains
|
||||
// after the borders and the index column.
|
||||
let fixed = 1 + 3 * (data_cols + 1) + index_width;
|
||||
capped[0] = max_width.saturating_sub(fixed).max(1);
|
||||
widths = capped;
|
||||
while data_cols > 1 {
|
||||
let min_sum: usize = min_w[..data_cols].iter().sum();
|
||||
if fixed(data_cols) + min_sum <= max_width {
|
||||
break;
|
||||
}
|
||||
data_cols -= 1;
|
||||
}
|
||||
|
||||
// The widest column is the "elastic" one and shrinks first when the
|
||||
// row overflows. Identity columns (name, pid…) therefore stay at
|
||||
// their natural width while content columns (path, description…)
|
||||
// absorb the truncation. Ties go to the rightmost column.
|
||||
let mut order: Vec<usize> = (0..data_cols).collect();
|
||||
order.sort_by(|&a, &b| natural[b].cmp(&natural[a]).then_with(|| b.cmp(&a)));
|
||||
|
||||
let mut widths: Vec<usize> = natural[..data_cols].to_vec();
|
||||
let budget = max_width.saturating_sub(fixed(data_cols));
|
||||
let total: usize = widths.iter().sum();
|
||||
if total > budget {
|
||||
// Shrink elastic columns toward their header width.
|
||||
let mut needed = total - budget;
|
||||
for &i in &order {
|
||||
let room = widths[i].saturating_sub(min_w[i]);
|
||||
let cut = room.min(needed);
|
||||
widths[i] -= cut;
|
||||
needed -= cut;
|
||||
if needed == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Last resort: even at header width the row overflows; truncate
|
||||
// the widest columns below their header (down to 1 char) so each
|
||||
// row still fits on a single line.
|
||||
for &i in &order {
|
||||
if needed == 0 {
|
||||
break;
|
||||
}
|
||||
let cut = widths[i].saturating_sub(1).min(needed);
|
||||
widths[i] -= cut;
|
||||
needed -= cut;
|
||||
}
|
||||
}
|
||||
|
||||
let all_headers: Vec<String> = std::iter::once("#".to_string())
|
||||
.chain(headers.iter().take(data_cols).cloned())
|
||||
.collect();
|
||||
@@ -434,19 +450,44 @@ mod tests {
|
||||
Cell::bytes(512, "512 B".into()),
|
||||
]],
|
||||
};
|
||||
// Trailing columns are dropped when too narrow.
|
||||
// The widest (name) column shrinks first; the trailing columns stay
|
||||
// visible instead of being dropped.
|
||||
let text = t.render(false, 45);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 45, "too wide: {line}");
|
||||
}
|
||||
assert!(!text.contains("size"));
|
||||
// The name column is truncated with an ellipsis at the very end.
|
||||
assert!(text.contains("size"), "size should stay visible:\n{text}");
|
||||
assert!(text.contains("file"));
|
||||
// At a very narrow width the name column is truncated with an
|
||||
// ellipsis, but the row still fits on a single line.
|
||||
let text = t.render(false, 30);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 30, "too wide: {line}");
|
||||
}
|
||||
assert!(text.contains("…"), "name should be truncated:
|
||||
{text}");
|
||||
assert!(text.contains("…"), "name should be truncated:\n{text}");
|
||||
assert!(text.contains("size"), "size should stay visible:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shrinks_content_column_before_name() {
|
||||
// Regression: the name column must not be padded at the expense of a
|
||||
// long trailing column (e.g. `am list` PATH).
|
||||
let t = DataTable {
|
||||
columns: vec!["name".into(), "path".into()],
|
||||
rows: vec![vec![
|
||||
Cell::str("claude-code"),
|
||||
Cell::str("/home/u/.local/share/agents/claude-code/bin/claude.exe"),
|
||||
]],
|
||||
};
|
||||
let text = t.render(false, 60);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 60, "too wide: {line}");
|
||||
}
|
||||
// The name stays at its natural width (no padding before the bar).
|
||||
assert!(text.contains("claude-code │"), "name padded:\n{text}");
|
||||
// The path is truncated with an ellipsis, not dropped.
|
||||
assert!(text.contains("…"), "path should be truncated:\n{text}");
|
||||
assert!(text.contains("/home/u/"), "path should stay visible:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -86,6 +86,138 @@ fn agents_command_lists_installed_only_in_installed_mode() {
|
||||
assert_eq!(agents_cmd::run(&app, true).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_command_filters_aliases_and_groups_by_launchable_target() {
|
||||
let dir = std::env::temp_dir().join(format!("am-test-alias-group-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = common::write_config(
|
||||
&dir,
|
||||
r#"
|
||||
agents:
|
||||
- name: claude-code
|
||||
display_name: "Claude Code"
|
||||
category: coding-agent
|
||||
install:
|
||||
type: npm
|
||||
package: "@anthropic-ai/claude-code"
|
||||
run: claude
|
||||
- name: github-copilot
|
||||
display_name: "GitHub Copilot"
|
||||
category: coding-agent
|
||||
install:
|
||||
type: npm
|
||||
package: "@github/copilot"
|
||||
run: copilot
|
||||
- name: aider
|
||||
display_name: "Aider"
|
||||
category: coding-agent
|
||||
install:
|
||||
type: uv
|
||||
package: aider-install
|
||||
run: aider
|
||||
aliases:
|
||||
cc: claude-code
|
||||
copilot: github-copilot
|
||||
groups:
|
||||
dev: [claude-code, aider]
|
||||
empty: [github-copilot]
|
||||
"#,
|
||||
);
|
||||
let cli = Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
cfg.to_str().unwrap(),
|
||||
"list",
|
||||
]);
|
||||
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
|
||||
common::isolate(&mut app, &dir);
|
||||
|
||||
let entry = InstalledEntry {
|
||||
name: "claude-code".to_string(),
|
||||
version: Some("1.0.0".to_string()),
|
||||
method: "npm".to_string(),
|
||||
install_dir: dir.join("claude-code").display().to_string(),
|
||||
bins: vec![],
|
||||
run: "claude".to_string(),
|
||||
installed_at: "2026-08-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
};
|
||||
app.state.set(&entry).unwrap();
|
||||
|
||||
let names = agents_cmd::agent_names(&app, true);
|
||||
assert!(
|
||||
names.contains(&"claude-code".to_string()),
|
||||
"installed agent must appear: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"cc".to_string()),
|
||||
"alias pointing to an installed agent must appear: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"group:dev".to_string()),
|
||||
"group with at least one installed member must appear: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"github-copilot".to_string()),
|
||||
"agent that is not installed must not appear: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"copilot".to_string()),
|
||||
"alias pointing to a non-installed agent must not appear: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"group:empty".to_string()),
|
||||
"group with no installed members must not appear: {names:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilt_completer_tracks_install_and_uninstall() {
|
||||
// The REPL completer snapshots the agent pool at startup; the line
|
||||
// editor is rebuilt after install/uninstall, and a rebuilt completer
|
||||
// must reflect the state file: a fresh install shows up under Tab and
|
||||
// an uninstall disappears — without restarting am.
|
||||
let app = common::test_app("refresh", &["list"]);
|
||||
let before = agent_manager::repl::AmCompleter::new(&app);
|
||||
let (_, cands_before) = before.candidates_for("start ", 6);
|
||||
assert!(
|
||||
!cands_before.contains(&"pi".to_string()),
|
||||
"pi must not be proposed before its install: {cands_before:?}"
|
||||
);
|
||||
|
||||
app.state
|
||||
.set(&InstalledEntry {
|
||||
name: "pi".to_string(),
|
||||
version: Some("0.84.2".to_string()),
|
||||
method: "binary".to_string(),
|
||||
install_dir: "/tmp/pi".to_string(),
|
||||
bins: vec![],
|
||||
run: "pi".to_string(),
|
||||
installed_at: "2026-08-17T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let after_install = agent_manager::repl::AmCompleter::new(&app);
|
||||
let (_, cands_installed) = after_install.candidates_for("start ", 6);
|
||||
assert!(
|
||||
cands_installed.contains(&"pi".to_string()),
|
||||
"a rebuilt completer must propose a freshly installed agent: {cands_installed:?}"
|
||||
);
|
||||
|
||||
app.state.remove("pi").unwrap();
|
||||
let after_uninstall = agent_manager::repl::AmCompleter::new(&app);
|
||||
let (_, cands_removed) = after_uninstall.candidates_for("start ", 6);
|
||||
assert!(
|
||||
!cands_removed.contains(&"pi".to_string()),
|
||||
"a rebuilt completer must drop an uninstalled agent: {cands_removed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_pages_are_written_to_output_dir() {
|
||||
let app = common::test_app("man", &["man"]);
|
||||
@@ -94,7 +226,7 @@ fn man_pages_are_written_to_output_dir() {
|
||||
let code = man_cmd::run(&app, None, Some(&dir)).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(dir.join("am.1").exists(), "am.1 missing");
|
||||
for name in ["start", "sessions", "stats", "log", "history", "dashboard", "favorite", "profile", "man"] {
|
||||
for name in ["start", "sessions", "stats", "log", "history", "dashboard", "favorite", "profile", "man", "tip"] {
|
||||
assert!(
|
||||
dir.join(format!("am-{name}.1")).exists(),
|
||||
"am-{name}.1 missing"
|
||||
@@ -109,6 +241,28 @@ fn man_pages_are_written_to_output_dir() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_page_renders_to_readable_text() {
|
||||
// The roff source of the real CLI page must render to readable text:
|
||||
// macros and font escapes gone, sections and option terms kept.
|
||||
let cmd = Cli::command();
|
||||
let man = clap_mangen::Man::new(cmd);
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
man.render(&mut buf).unwrap();
|
||||
let roff = String::from_utf8_lossy(&buf);
|
||||
let text = man_cmd::roff_to_text(&roff, agent_manager::theme::default_theme(), false);
|
||||
assert!(text.contains("▸ NAME"), "NAME section missing:\n{text}");
|
||||
assert!(text.contains("▸ SYNOPSIS"), "SYNOPSIS section missing");
|
||||
assert!(text.contains("▸ OPTIONS"), "OPTIONS section missing");
|
||||
assert!(text.contains("▸ SUBCOMMANDS"), "SUBCOMMANDS section missing");
|
||||
assert!(!text.contains(".SH"), "macros must be removed: {text}");
|
||||
assert!(!text.contains("\\fB"), "font escapes must be converted: {text}");
|
||||
// Option terms appear before their descriptions (term/body order).
|
||||
let pos_config = text.find("--config <FILE>").unwrap_or(0);
|
||||
let pos_desc = text.find("Use an alternate configuration file").unwrap_or(0);
|
||||
assert!(pos_config < pos_desc, "term must precede its body:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_man_command_is_rejected() {
|
||||
let app = common::test_app("manbad", &["man"]);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! tip: the cheat sheet page (render, JSON, --random, REPL completion).
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::cli::Cli;
|
||||
use agent_manager::commands::tip_cmd;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn tip_page_renders_header_sections_and_options() {
|
||||
let app = common::test_app("tip", &["tip"]);
|
||||
let text = tip_cmd::render(&app, false);
|
||||
assert!(text.contains("TIP"), "header missing:\n{text}");
|
||||
assert!(text.contains("antisèche"), "subtitle missing:\n{text}");
|
||||
for title in [
|
||||
"Lancer & piloter",
|
||||
"Installer & maintenir",
|
||||
"Observer",
|
||||
"Personnaliser",
|
||||
"Boîte à outils",
|
||||
] {
|
||||
assert!(text.contains(title), "missing section '{title}':\n{text}");
|
||||
}
|
||||
assert!(text.contains("start <agent>"), "missing usage line");
|
||||
assert!(text.contains("--background"), "missing option");
|
||||
assert!(text.contains("Le saviez-vous"), "missing tip of the day");
|
||||
assert!(
|
||||
text.contains("Vos commandes les plus utilisées"),
|
||||
"missing personal section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_json_output_is_structured() {
|
||||
let app = common::test_app("tip-json", &["tip", "--json"]);
|
||||
let text = tip_cmd::render(&app, false);
|
||||
let v: serde_json::Value = serde_json::from_str(&text).expect("valid JSON output");
|
||||
assert!(v["sections"].is_array(), "sections missing");
|
||||
assert_eq!(v["sections"].as_array().unwrap().len(), 5);
|
||||
assert!(v["tip"]["title"].is_string());
|
||||
assert!(v["tip"]["body"].is_string());
|
||||
assert!(v["most_used"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_random_prints_a_single_tip() {
|
||||
let app = common::test_app("tip-rnd", &["tip", "--random"]);
|
||||
let text = tip_cmd::render(&app, true);
|
||||
assert!(text.contains("💡"), "random tip missing: {text}");
|
||||
assert!(!text.contains("antisèche"), "random mode must not print the page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_run_succeeds() {
|
||||
let app = common::test_app("tip-run", &["tip"]);
|
||||
assert_eq!(tip_cmd::run(&app, false).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_tip_flags() {
|
||||
let cli = Cli::parse_from(["am", "tip", "--random"]);
|
||||
match cli.command {
|
||||
Some(agent_manager::cli::Command::Tip { random }) => assert!(random, "--random must be parsed"),
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_completion_proposes_tip() {
|
||||
let app = common::test_app("tip-comp", &["list"]);
|
||||
let c = agent_manager::repl::AmCompleter::new(&app);
|
||||
let (_, cands) = c.candidates_for("ti", 2);
|
||||
assert!(cands.contains(&"tip".to_string()), "candidates: {cands:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user