Files
agent-manager/src/commands/tip_cmd.rs
T
bruno 7312e6ceab feat: v0.7.0 — secrets partagés par provider : am providers set-token/token, namespace keyring providers/<nom>, résolution @secret en cascade agent → provider (closes #89)
Corrige au passage deux bugs du keyring (#36) : features natifs manquants du crate keyring (set() no-op silencieux, rien ne persistait) + index __index__ jamais purgé au remove (list fantômes).
2026-08-19 16:09:34 -04:00

749 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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: "suggest <requête>",
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
options: &[("--json", "sortie machine-readable")],
example: "suggest un agent pour du Python",
},
TipEntry {
usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
options: &[
("--prune", "purge les modèles inutilisés"),
("--dry-run", "simulation sans rien supprimer"),
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
],
example: "models --prune --dry-run",
},
TipEntry {
usage: "catalog update",
about: "rafraîchit le catalogue officiel (diff + confirmation)",
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml",
},
TipEntry {
usage: "audit",
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
options: &[("--json", "rapport machine-readable")],
example: "audit --json",
},
TipEntry {
usage: "service install <agent> --autostart",
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
options: &[
("status <agent>", "état du service"),
("uninstall <agent>", "retire le service"),
],
example: "service install claude-code --autostart",
},
TipEntry {
usage: "schedule add <cmd...> --at HH:MM",
about: "planifie une commande am quotidienne (ex: update --all le matin)",
options: &[
("schedule list", "liste les planifications"),
("schedule remove <id>", "supprime une planification"),
],
example: "schedule add update --all --at 06:30",
},
TipEntry {
usage: "doctor --watch <secondes>",
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
example: "doctor --watch 60",
},
TipEntry {
usage: "start group:dev --parallel",
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
example: "start group:dev --parallel",
},
TipEntry {
usage: "update --rollback",
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
options: &[
("--rollback list", "liste les points de restauration"),
("--rollback <id>", "restaure un point précis"),
],
example: "update --rollback list",
},
TipEntry {
usage: "init --template <stack>",
about: "génère une config projet pré-remplie (web, python, rust, cli)",
options: &[("--template list", "liste les templates disponibles")],
example: "init --template python",
},
TipEntry {
usage: "sessions --export <id>",
about: "exporte une session : métadonnées + commandes + extrait de log (JSON)",
options: &[
("--output <fichier>", "destination (défaut ./am-session-<id>.json)"),
("--retention <jours>", "purge les sessions terminées au-delà de N jours"),
],
example: "sessions --export 20260815_143926 --output session.json",
},
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"), ("stats --costs", "tokens + coût estimé par agent (issue #49)")],
example: "stats --period 30d --costs",
},
TipEntry {
usage: "monitor",
about: "TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils",
options: &[
("--interval <s>", "rafraîchissement (défaut 2s)"),
("--json", "flux JSON pour les scripts"),
],
example: "monitor --interval 5",
},
TipEntry {
usage: "web",
about: "dashboard web local en lecture seule : vue d'ensemble, graphiques, sessions, événements, projets (issue #54)",
options: &[
("--port <p>", "port d'écoute (défaut 7878)"),
("--no-open", "ne pas ouvrir le navigateur automatiquement"),
],
example: "web --port 9090",
},
TipEntry {
usage: "sync",
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
options: &[("--message <m>", "message de commit personnalisé"), ("sync_on_exit: true", "pousse automatiquement à la fermeture du REPL")],
example: "sync --message \"sauvegarde du soir\"",
},
TipEntry {
usage: "migrate",
about: "export/import d'un bundle de transfert machine A → B (config + état + historique + backups)",
options: &[("--export", "crée agent-manager-migrate.amx"), ("--output <f>", "chemin du bundle")],
example: "migrate --export --output backup.amx",
},
TipEntry {
usage: "history 12..25 --save deploy.yaml",
about: "exporte une plage d'historique en playbook YAML (état_dir/playbooks/)",
options: &[("--save <f>", "fichier playbook"), ("playbook <f>", "rejoue pas à pas avec confirmation")],
example: "history 12..25 --save deploy.yaml && playbook deploy.yaml",
},
TipEntry {
usage: "lab --agents claude-code,aider --task hello.yaml",
about: "benchmark : même tâche sur plusieurs agents (durée, exit, coût)",
options: &[("--parallel", "exécution concurrente"), ("--json", "rapport comparatif stable (scripts/CI)")],
example: "lab --agents claude-code,aider --task bench/hello.yaml --json",
},
TipEntry {
usage: "plugins --test notify",
about: "scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)",
options: &[("--test <name>", "exécute un plugin sur un événement de test (CI)")],
example: "plugins && plugins --test notify",
},
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: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring",
options: &[],
example: "providers set-token deepseek --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(crate::i18n::tr_in(app.lang(), title)),
crate::i18n::tr_in(app.lang(), 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, app.lang(), 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(crate::i18n::tr_in(app.lang(), title)),
crate::i18n::tr_in(app.lang(), body)
));
out.push('\n');
out.push_str(&format!(
" {}\n",
p.dim(crate::i18n::tr_in(
app.lang(),
"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} {} ", crate::i18n::tr_in(app.lang(), 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, lang: crate::i18n::Lang, e: &TipEntry) -> String {
let mut out = String::new();
out.push_str(&format!(
" {} {} {}\n",
p.inf("▸"),
p.acc(e.usage),
crate::i18n::tr_in(lang, e.about)
));
for (flag, desc) in e.options {
out.push_str(&format!(
" {} {}\n",
p.dim(flag),
crate::i18n::tr_in(lang, 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!(
" {} — {}\n",
p.dim(crate::i18n::tr_in(app.lang(), "aucune commande enregistrée")),
crate::i18n::tr_in(
app.lang(),
"lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici"
)
));
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());
}
}
}
}