feat: am tip cheat sheet + readable man pages + REPL completion refresh

This commit is contained in:
2026-08-17 21:50:48 -04:00
parent 53893f2967
commit 98c0b30f68
10 changed files with 1402 additions and 63 deletions
+7
View File
@@ -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),
+17 -3
View File
@@ -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
View File
@@ -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}");
}
}
+2
View File
@@ -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),
+588
View File
@@ -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(&section_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(&section_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(&section_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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+155 -1
View File
@@ -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"]);
+74
View File
@@ -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:?}");
}