//! 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 ", 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 ", "profil d'environnement (env + args)"), ], example: "start claude-code --background", }, TipEntry { usage: "stop ", about: "arrête un agent en arrière-plan (SIGTERM puis SIGKILL)", options: &[ ("--force", "kill immédiat, sans délai de grâce"), ("--timeout ", "délai de grâce avant le kill (défaut : 5 s)"), ], example: "stop pi", }, TipEntry { usage: "restart ", about: "redémarre avec les mêmes options (agent, groupe, profil…)", options: &[], example: "restart group:dev", }, TipEntry { usage: "watch ", 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 ", 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 ", "name, version ou status"), ("--category ", "filtre par catégorie"), ], example: "list --all", }, TipEntry { usage: "search ", about: "cherche dans le catalogue (nom, description, catégorie, tags)", options: &[], example: "search pi", }, TipEntry { usage: "suggest ", 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 ", "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 ", "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 --autostart", about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto", options: &[ ("status ", "état du service"), ("uninstall ", "retire le service"), ], example: "service install claude-code --autostart", }, TipEntry { usage: "schedule add --at HH:MM", about: "planifie une commande am quotidienne (ex: update --all le matin)", options: &[ ("schedule list", "liste les planifications"), ("schedule remove ", "supprime une planification"), ], example: "schedule add update --all --at 06:30", }, TipEntry { usage: "doctor --watch ", 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 --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 ", "restaure un point précis"), ], example: "update --rollback list", }, TipEntry { usage: "init --template ", 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 ", about: "exporte une session : métadonnées + commandes + extrait de log (JSON)", options: &[ ("--output ", "destination (défaut ./am-session-.json)"), ("--retention ", "purge les sessions terminées au-delà de N jours"), ], example: "sessions --export 20260815_143926 --output session.json", }, TipEntry { usage: "install ", about: "installe l'agent et ses dépendances (Node, Python, Rust…)", options: &[ ("--method ", "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 ", 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 ", 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 ", about: "lit la fin du journal de l'agent", options: &[ ("--follow", "suit le log en direct"), ("--lines ", "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

", "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 ", "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

", "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 ", "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 ", "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 ", "fichier playbook"), ("playbook ", "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 ", "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 ", "activité à partir d'une date")], example: "timeline --since 2026-08-01", }, ], }, TipSection { icon: "⚙️", title: "Personnaliser", entries: &[ TipEntry { usage: "alias add ", 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 ", about: "change la palette du shell, des tableaux et de cette page", options: &[], example: "theme ocean", }, TipEntry { usage: "secret set --agent --value ", 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 --base-url · set-token --value ", 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 ", "fichier de destination (défaut : am-export.json)")], example: "export --output backup.json", }, TipEntry { usage: "completion --installed", about: "complétion dynamique : agents installés, alias et groupes au Tab", options: &[], example: "completion powershell --installed", }, TipEntry { usage: "man ", about: "page de manuel pour une commande", options: &[], example: "man start", }, TipEntry { usage: "help --find ", 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 ' 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 { 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(§ion_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(§ion_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 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 = 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!( " {} — {}\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: (&'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 = 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::>(), "example": e.example, })).collect::>(), }) }) .collect(); let payload = json!({ "sections": sections, "most_used": most_used(app).iter().map(|(command, count)| json!({ "command": command, "count": count })).collect::>(), "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()); } } } }