Compare commits

...
12 Commits
Author SHA1 Message Date
bruno fa51cdb339 feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
release / linux (push) Successful in 14m16s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-18 16:40:15 -04:00
bruno 7f4d74f811 feat: phase 2 — i18n messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt, REPL lang) (closes #74) 2026-08-18 15:46:51 -04:00
bruno 4c7866a25a feat: phase 2 — am lab (benchmark N agents) + plugins d'événements (contrat JSON)
- #62: am lab --agents a,b --task <f> — séquentiel ou --parallel, capture
  durée/exit/sortie/coût estimé, rapport comparatif tableau + --json stable,
  tâches versionnables dans <state>/lab/ (--list), rejouables à l'identique
- #75: plugins/ sous state_dir, déclenchés sur on_install/on_start/on_stop/
  on_update (App::emit), contrat JSON stdin/stdout, timeout configurable,
  échec non bloquant, garde anti-récursion AM_PLUGINS_RUNNING, am plugins
  + am plugins --test <name> (CI), exemples notify + ci-webhook
- intégration REPL (parse, complétion, bannière, is_am_command), help, tips,
  man pages (am-lab.1, am-plugins.1), README, ROADMAP (26/27), v0.5.4
- 334 tests verts (309 + 25)

closes #62
closes #75
2026-08-18 14:17:30 -04:00
bruno aa0d8e43eb feat: phase 2 — playbooks (am history --save + am playbook, rejeu pas à pas) (closes #51) 2026-08-18 13:30:29 -04:00
bruno e82979e6a3 feat: phase 2 — sauvegarde git (am sync) + transfert machine (am migrate) (closes #66 #68) 2026-08-18 12:09:43 -04:00
bruno 1b3fe09074 feat: phase 2 — coûts par session (stats --costs) + am monitor TUI (closes #49 #50) 2026-08-18 11:23:50 -04:00
bruno 63dc36660b feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59) 2026-08-18 10:57:00 -04:00
bruno 6a6676fc5d feat: phase 2 — sessions export + rétention, init --template (closes #52 #53)
Axe 3 (#52) : templates embarqués dans le binaire (web, python, rust, cli)
- am init --template <stack> génère une config pré-remplie : groupe dev,
  profil par défaut, hooks projet (npm install / uv sync / cargo fetch)
- substitution {project} {dir} au rendu ; --template list pour lister
- fallback détection automatique de stack quand aucun template
- config générée validée (parse YAML + validate) par les tests

Axe 4 (#53) : am sessions export + rétention configurable
- am sessions --export <id> [--output <fichier>] : JSON complet et
  reproductible (métadonnées + commandes history/<id>.jsonl + extrait de
  log 4 Ko + rendu lisible 'readable'), compatible archivage manuel
- settings.sessions_retention_days (défaut 90) : purge au démarrage
  (silencieuse) et am sessions --retention <jours>
- purge limitée à l'index dérivé sessions.json — le journal d'événements
  primaire n'est jamais touché ; EventKind::Prune journalisé (visibilité
  am audit)
- sessions en cours (running) jamais purgées ; erreur claire si id inconnu

REPL : sessions --export/--retention/--output + init --template ; help
specs, tip cheat sheet, config.yaml, README, man pages régénérées.
Version 0.4.7, ROADMAP cochée, 276 tests verts.
2026-08-18 10:08:20 -04:00
bruno d9ffc34156 feat: phase 2 — audit config + update --rollback (closes #63 #64)
Axe 8 (sécurité) :
- am audit : checksums SHA-256 des configs (embarquée, utilisateur,
  locale) stockés dans state.json, croisement avec events.jsonl pour
  dater les changements, détection des modifications manuelles hors de
  am (statut MANUAL), rapport lisible + --json stable (contrat)
- backup automatique avant chaque am update (agent ou --all) : état +
  config utilisateur + répertoires d'installation + shims dans
  <state>/backups/<ts>/ avec manifest.json
- am update --rollback [point] : restaure le dernier backup (ou un id
  précis, préfixe accepté), arrête les agents concernés d'abord,
  événement EventKind::Rollback journalisé
- am update --rollback list : liste les points de restauration
- rétention settings.backups_keep (défaut 5) appliquée après chaque backup
- événements EventKind::Backup / EventKind::Rollback pour l'audit

REPL : commande audit + update --rollback (parse, complétion), help specs,
tip cheat sheet, man pages régénérées (50 pages). Version 0.4.6,
ROADMAP cases cochées, 272 tests verts.
2026-08-18 09:44:16 -04:00
bruno 22bc529818 feat: phase 2 — modèles locaux + catalogue distant + suggest (closes #70 #71 #73 #60 #67 #61)
Axe 11 (modèles) :
- am models : inventaire des runtimes locaux (ollama list --format json,
  llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon
  probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale
- am run <agent> --model <m> : résolution dans l'inventaire, transmission
  via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session
- am models --prune : candidats inutilisés depuis settings.models_prune_days
  (défaut 30), :latest toujours protégé, dry-run + confirmation + événement
  EventKind::Model journalisé

Axes 7/9 (catalogue) :
- am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/
  modifiés), confirmation, enregistrement en include
- am catalog add <url> : validation du format (parse + validate), cache
  catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé
- include par URL (http/https/file) dans config.yaml, résolu au chargement
  avec cache + TTL ; override local toujours prioritaire (issue #67)
- am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par
  usage réel (lancements + durée depuis le journal), top 5 justifié, --json

REPL : commandes models/catalog/suggest (parse, complétion, descriptions),
help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles).
Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
2026-08-18 09:06:16 -04:00
bruno 890ad953e5 feat: phase 2 quick wins (#65 #69 #72)
- #65: update policies + per-agent version pinning
  - add settings.update_policy (stable/latest/skip)
  - add AgentDef.pin_version
  - honor both in update_cmd
  - add update_policy tests

- #69: REPL color theme CLI command
  - add am theme [name] (show/switch)
  - persist selected theme to config

- #72: doctor checks local model runtimes
  - verify ollama and llama-server on PATH
  - for ollama, also check server responds (ollama ls)

Version 0.4.4
2026-08-17 22:24:45 -04:00
bruno 98c0b30f68 feat: am tip cheat sheet + readable man pages + REPL completion refresh 2026-08-17 21:50:48 -04:00
105 changed files with 13550 additions and 312 deletions
+5 -2
View File
@@ -2,7 +2,7 @@
> Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux.
>
> 📦 Version : `0.4.3` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
> 📦 Version : `0.6.0` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
---
@@ -28,6 +28,7 @@ flowchart TB
CLI["Ligne de commande<br/>clap v4"]
REPL["Shell interactif<br/>rustyline"]
TUI["Dashboard TUI<br/>ratatui + crossterm"]
WEB["Dashboard web<br/>tiny_http (127.0.0.1)"]
end
subgraph CORE["⚙️ Noyau applicatif"]
@@ -146,7 +147,9 @@ src/
├── help.rs ❓ Aide Nushell-style
├── output.rs 🖨️ Logger et rendu
├── tables.rs 📋 Rendu tabulaire
├── theme.rs 🎨 Thèmes de couleur
├── theme.rs 🎨 Thèmes de couleur (REPL et tableaux)
├── web.rs 🌐 Serveur HTTP local (127.0.0.1) + API JSON + frontend embarqué
├── frontend/ 📄 index.html — dashboard web (HTML5 + CSS + JS vanilla, include_str!)
├── history.rs ⏪ Historique des commandes
├── sessions.rs 📅 Registre des sessions
├── projects.rs 🗂️️ Agrégation par projet
Generated
+32 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.4.3"
version = "0.6.0"
dependencies = [
"anyhow",
"chrono",
@@ -43,6 +43,7 @@ dependencies = [
"shell-words",
"tar",
"tempfile",
"tiny_http",
"ureq",
"wait-timeout",
"which",
@@ -139,6 +140,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "atomic"
version = "0.6.1"
@@ -296,6 +303,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -839,6 +852,12 @@ dependencies = [
"digest",
]
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -2260,6 +2279,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.4"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.4.3"
version = "0.6.0"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -31,6 +31,7 @@ sha2 = "0.10"
shell-words = "1"
tar = "0.4"
tempfile = "3"
tiny_http = "0.12"
ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2"
keyring = "3"
+24
View File
@@ -77,8 +77,25 @@ plateforme, il compile automatiquement depuis les sources.
| am list | agents installés (gérés + détectés sur le PATH) |
| am list --all | tout le catalogue, avec l'état de chacun |
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs |
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
| am audit | qui a modifié quoi, quand (checksums config + journal) |
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
| am doctor --watch <s> | vérifications périodiques de l'environnement + alerte en cas de panne |
| am monitor [--json] | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils |
| am stats --costs | coût estimé par agent (tokens in/out, $) — modèles de prix configurables |
| am sync [--message <m>] | sauvegarde git de l'état (state.json, journal, historique) — secrets exclus |
| am migrate export/import | bundle de transfert machine A → B (config + état + historique + backups) |
| am service install <agent> --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto |
| am schedule add <cmd...> --at HH:MM | planifie une commande am (ex: update --all) ; list / remove / run |
| am start group:dev --parallel | orchestration de groupes : ordre, --parallel, attente de santé (healthcheck) |
| am run <agent> --container | exécute l'agent dans son profil conteneur (docker/podman) |
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
@@ -118,8 +135,12 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am projects [nom] | quel agent travaille sur quel projet (agent dominant, sessions, durées) |
| am sessions [agent] --status --show --resume | registre des sessions + reprise d'une session arrêtée |
| am history --search --failed --rerun N | historique des commandes (am + shell), filtrable et réexécutable |
| am history 12..25 --save <f> · am playbook <f> | exporte une plage en playbook YAML et la rejoue pas à pas ({{var}}) |
| am lab --agents a,b --task <f> [--parallel] [--json] | benchmark : même tâche sur plusieurs agents (durée, exit, coût) — tâches versionnables dans <state>/lab/ |
| am plugins [--test <nom>] | scripts d'extension sur les événements (on_install/on_start/on_stop/on_update), contrat JSON stdin/stdout, timeout — exemples dans examples/plugins/ |
| am logs <agent> --follow | tail du log d'un agent, en direct |
| am dashboard | tableau de bord TUI temps réel : vue d'ensemble, activité, stats, sessions, projets (Tab/←/→ : onglets, j/k : défilement, q : quitter) |
| am web [--port <p>] | dashboard web local en lecture seule (127.0.0.1) : vue d'ensemble, graphiques, sessions, événements, projets — API JSON /api/* avec les mêmes contrats que les sorties --json |
### 🎯 Personnaliser
@@ -190,6 +211,7 @@ Disponibles avant **ou après** la sous-commande.
| -v, --verbose | affiche chaque commande exécutée |
| -q, --quiet | uniquement les erreurs |
| --no-color | désactive les couleurs |
| --lang <fr\|en> | langue des messages : fr (défaut) ou en — AM_LANG/LANG lus sinon |
| -c, --config <fichier> | configuration alternative |
---
@@ -202,6 +224,7 @@ Disponibles avant **ou après** la sous-commande.
am start cc --profile dev # profil d'environnement : env + args injectés
am dashboard # TUI temps réel : toute l'activité d'un coup d'œil
am web # dashboard web local (ouvre le navigateur) : http://127.0.0.1:7878/
am favorite cc && am tag cc python # personnalisez votre parc
am search --tag python # retrouvez les agents que vous avez tagués
@@ -209,6 +232,7 @@ Disponibles avant **ou après** la sous-commande.
❯ inst<Tab> # complétion Tab : commandes, agents installés, groupes, options
❯ install jcode
❯ dashboard # le dashboard se lance aussi depuis le REPL
❯ web # ... et le dashboard web aussi (am web --port pour changer le port)
❯ exit # historique persistant (flèches haut/bas)
### 🐚 Passerelle système
+37 -37
View File
@@ -169,8 +169,8 @@ taux de succès** — et **combien ça coûte**.
| am top — classement des agents par utilisation | S | P1 |
| am log — filtres agent/type/date, --follow | S → M | P0 → P1 |
| am report — rapport hebdo/mensuel en markdown (top agents, échecs, changements) | M | P1 |
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) | M | P2 |
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes | L | P2 |
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) ✅ #49 | M | P2 |
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes ✅ #50 | L | P2 |
| Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
am stats # vue globale
@@ -188,7 +188,7 @@ taux de succès** — et **combien ça coûte**.
| am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 |
| Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 |
| am history --rerun 42 (réexécution confirmée) | S | P1 |
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml | L | P2 |
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml ✅ #51 | L | P2 |
am history --search "install" --failed
am history --rerun 42
@@ -245,11 +245,11 @@ consultable, reprenable, archivable.
|---|---|---|
| Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 |
| am watch claude-code --restart-on-crash --notify | M | P1 |
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart | L | P2 |
| Planification : am schedule --daily update --all + check santé (cron / Task Scheduler) | M | P2 |
| Orchestration de groupes : ordre, --parallel, attente de santé | L | P2 |
| Conteneurs : profil docker/podman par agent (isolation à la demande) | L | P2 |
| am doctor --watch — vérifications périodiques avec alertes | M | P2 |
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart ✅ #55 | L | P2 |
| Planification : am schedule add update --all --at 06:30 + check santé (cron / Task Scheduler) ✅ #56 | M | P2 |
| Orchestration de groupes : ordre, --parallel, attente de santé ✅ #57 | L | P2 |
| Conteneurs : profil docker/podman par agent (isolation à la demande) ✅ #58 | L | P2 |
| am doctor --watch — vérifications périodiques avec alertes ✅ #59 | M | P2 |
### Axe 7 — 🧠 Intelligence & catalogue
@@ -284,9 +284,9 @@ consultable, reprenable, archivable.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 |
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL ✅ #66 | L | P2 |
| Partage de catalogue d'équipe : include par URL | S | P2 |
| am migrate — assistant de transfert machine A → B | M | P2 |
| am migrate — assistant de transfert machine A → B ✅ #68 | M | P2 |
| am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
### Axe 10 — 🧩 Confort & personnalisation
@@ -430,33 +430,33 @@ moins de 2 secondes.
| # | Issue | Effort |
|---|---|---|
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M |
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) | L |
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L |
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | Templates — am init --template web | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | am sessions export + rétention configurable | M |
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L |
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L |
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | Politiques — pin de version, settings.update_policy | S |
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | Partage de catalogue d'équipe (include par URL) | S |
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M |
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | Thèmes couleurs du REPL | S |
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | Lien agent ↔ modèle — am run --model | M |
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | doctor vérifie ollama / llama-server comme n'importe quel outil | S |
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | am models prune — purge des modèles inutilisés | S |
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L |
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | Plugin scripts (hooks avancés, intégration CI) | M |
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session (stats --costs, cost_models) ✅ | M |
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) ✅ | L |
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | ✅ Playbooks — am history --save + am playbook | L |
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M |
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | ✅ am web — serveur local + API JSON + graphiques (dashboard embarqué, contrats --json, 127.0.0.1) | XL |
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) ✅ | L |
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (add --at / list / remove / run) ✅ | M |
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) ✅ | L |
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent ✅ | L |
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes ✅ | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | ✅ am lab — benchmark : même tâche sur N agents | L |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | ✅ am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | ✅ am update --rollback — backup automatique avant mise à jour | M |
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S |
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | ✅ Synchronisation git automatique — am sync (sync_repo, sync_on_exit, secrets exclus) | L |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S |
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | ✅ am migrate — assistant de transfert machine A → B (bundle .amx + doctor) | M |
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | ✅ Thèmes couleurs du REPL | S |
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | ✅ am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | ✅ Lien agent ↔ modèle — am run --model | M |
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | ✅ doctor vérifie ollama / llama-server comme n'importe quel outil | S |
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | ✅ am models prune — purge des modèles inutilisés | S |
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | ✅ i18n — messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt) | L |
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | ✅ Plugin scripts (hooks avancés, intégration CI) | M |
Critère de sortie du jalon : parc auto-supervisé (crash = redémarrage +
alerte).
+23
View File
@@ -20,6 +20,29 @@ settings:
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
self_update_repo: Projets/agent-manager # repo used by am self-update (release assets)
self_update_base_url: https://git.dracodev.net/api/v1 # serveur de release (Gitea ici; GitHub: https://api.github.com)
models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73)
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
sessions_retention_days: 90 # purge des sessions terminées au-delà de N jours (#53)
# Prix des modèles (USD par million de tokens) pour am stats --costs (#49).
# cost_models: # décommenter pour surcharger
# claude-sonnet-4: { input_per_mtok: 3.0, output_per_mtok: 15.0 }
# deepseek-chat: { input_per_mtok: 0.27, output_per_mtok: 1.10 }
# Seuils d'alerte de am monitor (#50) : 0 désactive le seuil.
# monitor_thresholds:
# cpu_pct: 80.0
# mem_mb: 2048
# Plugins d'événements (#75) : scripts exécutés sur on_install/on_start/
# on_stop/on_update, contrat JSON stdin/stdout, timeout, échec non bloquant.
# Le répertoire <state>/plugins/ contient les scripts (+ manifeste .yaml).
# plugins:
# timeout_secs: 10 # temps max d'un plugin (défaut 10 s)
# enabled: [notify] # vide = tous les plugins du répertoire
# Sauvegarde git de l'état (#66) : dépôt où am sync pousse state.json,
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
# sync_repo: https://git.dracodev.net/bruno/am-state.git
# sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in)
# --- Command aliases ----------------------------------------------------------
aliases:
+8
View File
@@ -0,0 +1,8 @@
# Tâche de benchmark (issue #62) : les mêmes arguments sont passés à la
# commande de chaque agent. Copier dans <state_dir>/lab/ ou passer un
# chemin explicite :
# am lab --agents claude-code,aider --task hello.yaml --json
name: hello
description: "Répondre à la question la plus simple (sanity check du lab)."
args: ["--prompt", "Réponds uniquement 'coucou' et rien d'autre."]
# timeout: 300 # secondes par agent (défaut 600) — --timeout l'emporte
+31
View File
@@ -0,0 +1,31 @@
# Plugin scripts — agent-manager (issue #75)
#
# Les plugins sont des points d'extension par script, exécutés sur les
# événements du cycle de vie des agents (complément des hooks de l'axe 6) :
# on_install · on_start · on_stop · on_update
#
# Contrat :
# - entrée : l'événement JSON sur stdin
# {"event":"on_start","kind":"start","ts":"…","agent":"claude-code",
# "pid":1234,"cwd":"…","project":"…","args":[…],"env_keys":[…],…}
# - sortie : un objet JSON sur stdout
# {"ok": true, "message": "…", "data": {…}}
# ou {"ok": false, "error": "…"} — signalé en warning, jamais fatal
# - une sortie invalide (pas de JSON) est signalée en warning sans casser am
# - un plugin qui dépasse son timeout est tué (défaut 10 s)
# - les plugins n'ont pas accès aux valeurs des secrets (env_keys seulement)
# - AM_PLUGINS_RUNNING=1 est exporté pendant un plugin : un am imbriqué
# saute les plugins (anti-récursion)
#
# Installation :
# cp notify.py notify.yaml ~/.local/state/agent-manager/plugins/ (Linux)
# cp notify.py notify.yaml %LOCALAPPDATA%\agent-manager\plugins\ (Windows)
#
# Vérification :
# am plugins # liste les plugins découverts
# am plugins --test notify # exécute le contrat sur un événement de test
#
# Configuration (config.yaml) :
# plugins:
# timeout_secs: 10 # temps max d'un plugin (défaut 10 s)
# enabled: [notify] # vide = tous les plugins du répertoire
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Plugin exemple (issue #75) : poste les événements install/update vers un
webhook CI. Si la variable d'environnement CI_WEBHOOK_URL n'est pas définie,
le plugin répond ok sans rien faire (échec non bloquant, jamais fatal).
Exemple : CI_WEBHOOK_URL=https://hooks.example.com/am-ci am update --all
"""
import json
import os
import sys
import urllib.request
def main() -> int:
ev = json.load(sys.stdin)
url = os.environ.get("CI_WEBHOOK_URL")
if not url:
print(json.dumps({"ok": True, "message": "CI_WEBHOOK_URL non définie — rien à faire"}))
return 0
req = urllib.request.Request(
url,
data=json.dumps(ev).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
status = resp.status
except Exception as exc: # échec non bloquant : warning seulement
print(json.dumps({"ok": False, "error": f"webhook: {exc}"}))
return 0
print(json.dumps({"ok": True, "message": f"webhook {status}"}))
return 0
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -0,0 +1,4 @@
# Manifest du plugin ci-webhook (issue #75).
command: "python ci-webhook.py" # Linux : "python3 ci-webhook.py"
events: [on_install, on_update]
timeout_secs: 10
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Plugin exemple (issue #75) : notifie le démarrage / l'arrêt d'un agent.
Contrat : l'événement JSON arrive sur stdin, la réponse JSON part sur stdout.
Installation : copier notify.py + notify.yaml dans <state_dir>/plugins/ puis
relancer am. Test : am plugins --test notify
"""
import json
import sys
def main() -> int:
ev = json.load(sys.stdin)
agent = ev.get("agent") or "inconnu"
event = ev.get("event", "?")
pid = ev.get("pid")
detail = f" (pid {pid})" if pid else ""
print(json.dumps({"ok": True, "message": f"{event} de {agent}{detail}"}))
return 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -0,0 +1,7 @@
# Manifest du plugin notify (issue #75).
# command: interpréteur + script (indispensable sur Windows pour .py).
command: "python notify.py" # Linux : "python3 notify.py"
# Événements écoutés (défaut : les 4 événements du cycle de vie).
events: [on_start, on_stop]
# Temps maximal d'exécution avant kill (défaut : settings.plugins.timeout_secs, 10).
timeout_secs: 5
+13
View File
@@ -0,0 +1,13 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-audit 1 "audit "
.SH NAME
audit \- Audit the configuration: who changed what, when (issue #63)
.SH SYNOPSIS
\fBaudit\fR [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Audit the configuration: who changed what, when (issue #63)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+26
View File
@@ -0,0 +1,26 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-catalog 1 "catalog "
.SH NAME
catalog \- Manage remote catalogs: update the official one, add external ones
.SH SYNOPSIS
\fBcatalog\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Manage remote catalogs: update the official one, add external ones
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
catalog\-update(1)
Fetch the official catalog, show the diff and register it as an include
.TP
catalog\-add(1)
Register an external catalog (same YAML format) by URL
.TP
catalog\-list(1)
List the registered remote catalogs
.TP
catalog\-help(1)
Print this message or the help of the given subcommand(s)
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
doctor \- Check the environment: tools, config validity, paths, permissions
.SH SYNOPSIS
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-\-watch\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Check the environment: tools, config validity, paths, permissions
.SH OPTIONS
@@ -12,5 +12,8 @@ Check the environment: tools, config validity, paths, permissions
\fB\-\-fix\fR
Attempt to repair problems (create directories, fix the state file)
.TP
\fB\-\-watch\fR \fI<SECONDS>\fR
Re\-run the checks every N seconds, alerting on failure (issue #59)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+7 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
history \- Search the command history (am and shell commands)
.SH SYNOPSIS
\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-\-save\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIRANGE\fR]
.SH DESCRIPTION
Search the command history (am and shell commands)
.SH OPTIONS
@@ -30,5 +30,11 @@ Maximum number of entries (default: 100)
\fB\-\-rerun\fR \fI<N>\fR
Re\-run the Nth most recent entry (with confirmation)
.TP
\fB\-\-save\fR \fI<FILE>\fR
Export the range as a playbook YAML file (issue #51)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fIRANGE\fR]
Range to export as a playbook: "12..25" (1\-based, newest first)
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
init \- Generate a local agent\-manager.yaml for the current directory
.SH SYNOPSIS
\fBinit\fR [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBinit\fR [\fB\-\-force\fR] [\fB\-\-template\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Generate a local agent\-manager.yaml for the current directory
.SH OPTIONS
@@ -12,5 +12,8 @@ Generate a local agent\-manager.yaml for the current directory
\fB\-\-force\fR
Overwrite an existing agent\-manager.yaml
.TP
\fB\-\-template\fR \fI<TEMPLATE>\fR
Use a template (web, python, rust, cli) instead of stack detection; "list" shows the available templates
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+28
View File
@@ -0,0 +1,28 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-lab 1 "lab "
.SH NAME
lab \- Benchmark: run the same task on several agents and compare (issue #62)
.SH SYNOPSIS
\fBlab\fR [\fB\-\-agents\fR] [\fB\-\-task\fR] [\fB\-\-parallel\fR] [\fB\-\-timeout\fR] [\fB\-\-list\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Benchmark: run the same task on several agents and compare (issue #62)
.SH OPTIONS
.TP
\fB\-\-agents\fR \fI<AGENTS>\fR
Comma\-separated agent names (or aliases) to benchmark
.TP
\fB\-\-task\fR \fI<FILE>\fR
Task file (a bare name resolves under state_dir/lab/, .yaml appended)
.TP
\fB\-\-parallel\fR
Run every agent concurrently instead of one after another
.TP
\fB\-\-timeout\fR \fI<SECS>\fR
Override the task timeout in seconds
.TP
\fB\-\-list\fR
List the task files of the lab directory
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+22
View File
@@ -0,0 +1,22 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-migrate 1 "migrate "
.SH NAME
migrate \- Transfer the installation and state to another machine (issue #68)
.SH SYNOPSIS
\fBmigrate\fR [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIBUNDLE\fR]
.SH DESCRIPTION
Transfer the installation and state to another machine (issue #68)
.SH OPTIONS
.TP
\fB\-\-export\fR
Export the bundle (default) or import one
.TP
\fB\-\-output\fR \fI<FILE>\fR
Bundle path for export (default agent\-manager\-migrate.amx)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fIBUNDLE\fR]
Bundle path to import
+25
View File
@@ -0,0 +1,25 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-models 1 "models "
.SH NAME
models \- List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.SH SYNOPSIS
\fBmodels\fR [\fB\-\-prune\fR] [\fB\-\-dry\-run\fR] [\fB\-\-yes\fR] [\fB\-\-days\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.SH OPTIONS
.TP
\fB\-\-prune\fR
Purge unused models instead of listing them
.TP
\fB\-\-dry\-run\fR
Only list what would be pruned, delete nothing
.TP
\fB\-\-yes\fR
Answer yes to the deletion prompt
.TP
\fB\-\-days\fR \fI<DAYS>\fR
Consider a model unused after N days (default: 30)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-monitor 1 "monitor "
.SH NAME
monitor \- Real\-time monitor of the managed processes (issue #50)
.SH SYNOPSIS
\fBmonitor\fR [\fB\-\-interval\fR] [\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Real\-time monitor of the managed processes (issue #50)
.SH OPTIONS
.TP
\fB\-\-interval\fR \fI<SECONDS>\fR
Refresh interval in seconds (default 2)
.TP
\fB\-\-json\fR
Print one JSON document per tick instead of the TUI
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-playbook 1 "playbook "
.SH NAME
playbook \- Replay a saved playbook step by step with confirmation (issue #51)
.SH SYNOPSIS
\fBplaybook\fR [\fB\-\-var\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIFILE\fR>
.SH DESCRIPTION
Replay a saved playbook step by step with confirmation (issue #51)
.SH OPTIONS
.TP
\fB\-\-var\fR \fI<NAME=VALUE>\fR
Variable values, repeatable: \-\-var name=value
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIFILE\fR>
Playbook file (state_dir/playbooks/ when a bare name is given)
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-plugins 1 "plugins "
.SH NAME
plugins \- List the event plugins and test one (issue #75)
.SH SYNOPSIS
\fBplugins\fR [\fB\-\-test\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
List the event plugins and test one (issue #75)
.SH OPTIONS
.TP
\fB\-\-test\fR \fI<NAME>\fR
Run one plugin against a synthetic event and print its response
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+10 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
restart \- Restart an agent: stop, then start with the same options
.SH SYNOPSIS
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Restart an agent: stop, then start with the same options
.SH OPTIONS
@@ -27,6 +27,15 @@ Send a desktop notification once the agent has started
\fB\-\-profile\fR \fI<NAME>\fR
Apply an environment profile (env + args, defined in the config)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-\-force\fR
Kill immediately instead of terminating gracefully
.TP
+7 -1
View File
@@ -4,11 +4,17 @@
.SH NAME
run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS
\fBrun\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
.SH DESCRIPTION
Run the agent command directly with the given arguments (no process management)
.SH OPTIONS
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+29
View File
@@ -0,0 +1,29 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-schedule 1 "schedule "
.SH NAME
schedule \- Schedule am commands (issue #56)
.SH SYNOPSIS
\fBschedule\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Schedule am commands (issue #56)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
schedule\-add(1)
Add a daily schedule (ex: \*(Aqam schedule add update \-\-all\*(Aq)
.TP
schedule\-list(1)
List the schedules
.TP
schedule\-remove(1)
Remove a schedule by id
.TP
schedule\-run(1)
Run a schedule now (invoked by the OS task, issue #56)
.TP
schedule\-help(1)
Print this message or the help of the given subcommand(s)
+26
View File
@@ -0,0 +1,26 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-service 1 "service "
.SH NAME
service \- Register an agent as a system service (issue #55)
.SH SYNOPSIS
\fBservice\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Register an agent as a system service (issue #55)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
service\-install(1)
Install the service unit (systemd / launchd / Task Scheduler)
.TP
service\-uninstall(1)
Remove the service unit
.TP
service\-status(1)
Show whether the service is installed
.TP
service\-help(1)
Print this message or the help of the given subcommand(s)
+10 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
sessions \- List the sessions of every agent (and of the interactive shell)
.SH SYNOPSIS
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-\-retention\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
List the sessions of every agent (and of the interactive shell)
.SH OPTIONS
@@ -21,6 +21,15 @@ Show one session in detail (summary + log excerpt)
\fB\-\-resume\fR \fI<ID>\fR
Resume a finished session: relaunch the agent with its recorded arguments
.TP
\fB\-\-export\fR \fI<ID>\fR
Export one session (metadata, commands, log excerpt) to a JSON file
.TP
\fB\-\-output\fR \fI<FILE>\fR
Destination of the export (default: ./am\-session\-<ID>.json)
.TP
\fB\-\-retention\fR \fI<DAYS>\fR
Purge sessions finished more than N days ago (settings default 90)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+10 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
start \- Start an agent (foreground by default, or detached with \-\-background)
.SH SYNOPSIS
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Start an agent (foreground by default, or detached with \-\-background)
.SH OPTIONS
@@ -27,6 +27,15 @@ Send a desktop notification once the agent has started
\fB\-\-profile\fR \fI<NAME>\fR
Apply an environment profile (env + args, defined in the config)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
stats \- Show usage statistics computed from the event journal
.SH SYNOPSIS
\fBstats\fR [\fB\-\-period\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBstats\fR [\fB\-\-period\fR] [\fB\-\-costs\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Show usage statistics computed from the event journal
.SH OPTIONS
@@ -12,6 +12,9 @@ Show usage statistics computed from the event journal
\fB\-\-period\fR \fI<PERIOD>\fR
Only events of the last period (7d, 30d, 90d, all)
.TP
\fB\-\-costs\fR
Show token usage and estimated spend per agent (issue #49)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-suggest 1 "suggest "
.SH NAME
suggest \- Suggest agents matching a query, boosted by real usage (issue #61)
.SH SYNOPSIS
\fBsuggest\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIQUERY\fR>
.SH DESCRIPTION
Suggest agents matching a query, boosted by real usage (issue #61)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIQUERY\fR>
Free\-form request ("un agent pour du Python")
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-sync 1 "sync "
.SH NAME
sync \- Push the state into the configured git repository (issue #66)
.SH SYNOPSIS
\fBsync\fR [\fB\-\-message\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Push the state into the configured git repository (issue #66)
.SH OPTIONS
.TP
\fB\-\-message\fR \fI<MESSAGE>\fR
Commit message (default: "am sync — state update")
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-theme 1 "theme "
.SH NAME
theme \- Set or show the active color theme (persisted for the next run)
.SH SYNOPSIS
\fBtheme\fR [\fB\-h\fR|\fB\-\-help\fR] [\fINAME\fR]
.SH DESCRIPTION
Set or show the active color theme (persisted for the next run)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fINAME\fR]
Theme name (omit to list available themes)
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-tip 1 "tip "
.SH NAME
tip \- Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.SH SYNOPSIS
\fBtip\fR [\fB\-\-random\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.SH OPTIONS
.TP
\fB\-\-random\fR
Show a single random tip instead of the whole page
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
update \- Update an installed agent to the latest available version
.SH SYNOPSIS
\fBupdate\fR [\fB\-\-all\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBupdate\fR [\fB\-\-all\fR] [\fB\-\-rollback\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Update an installed agent to the latest available version
.SH OPTIONS
@@ -12,6 +12,9 @@ Update an installed agent to the latest available version
\fB\-\-all\fR
Update every installed managed agent
.TP
\fB\-\-rollback\fR [\fI<ROLLBACK>\fR]
Roll back to a pre\-update backup instead of updating ("latest" or a backup id; "\-\-rollback list" shows them)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-web 1 "web "
.SH NAME
web \- Local read\-only web dashboard with charts (issue #54)
.SH SYNOPSIS
\fBweb\fR [\fB\-\-port\fR] [\fB\-\-no\-open\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Local read\-only web dashboard with charts (issue #54)
.SH OPTIONS
.TP
\fB\-\-port\fR \fI<PORT>\fR
Port to listen on (default 7878)
.TP
\fB\-\-no\-open\fR
Do not open the browser automatically
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+61 -3
View File
@@ -1,10 +1,10 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.4.3"
.TH am 1 "am 0.6.0"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-\-lang\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
agent\-manager (am) — a CLI to manage local AI coding agents.
.PP
@@ -38,6 +38,19 @@ Disable ANSI colors
\fB\-\-theme\fR \fI<THEME>\fR
Color theme for the output (see \*(Aqam help theme\*(Aq for the list)
.TP
\fB\-\-lang\fR \fI<LANG>\fR
Output language: fr (default) or en (issue #74)
.br
.br
\fIPossible values:\fR
.RS 14
.IP \(bu 2
fr
.IP \(bu 2
en
.RE
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help (see a summary with \*(Aq\-h\*(Aq)
.TP
@@ -84,12 +97,33 @@ Manage environment profiles (dev, prod, ...)
am\-man(1)
Generate a man page for am (or for one command)
.TP
am\-theme(1)
Set or show the active color theme (persisted for the next run)
.TP
am\-alias(1)
Manage command aliases (cc \-> claude\-code)
.TP
am\-secret(1)
Manage secrets in the OS keyring (never in plaintext config)
.TP
am\-audit(1)
Audit the configuration: who changed what, when (issue #63)
.TP
am\-service(1)
Register an agent as a system service (issue #55)
.TP
am\-schedule(1)
Schedule am commands (issue #56)
.TP
am\-models(1)
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.TP
am\-catalog(1)
Manage remote catalogs: update the official one, add external ones
.TP
am\-suggest(1)
Suggest agents matching a query, boosted by real usage (issue #61)
.TP
am\-start(1)
Start an agent (foreground by default, or detached with \-\-background)
.TP
@@ -108,6 +142,18 @@ List the sessions of every agent (and of the interactive shell)
am\-stats(1)
Show usage statistics computed from the event journal
.TP
am\-monitor(1)
Real\-time monitor of the managed processes (issue #50)
.TP
am\-web(1)
Local read\-only web dashboard with charts (issue #54)
.TP
am\-sync(1)
Push the state into the configured git repository (issue #66)
.TP
am\-migrate(1)
Transfer the installation and state to another machine (issue #68)
.TP
am\-top(1)
Show the most used agents (top 10)
.TP
@@ -138,6 +184,15 @@ Search the catalog by keyword (name, description, category, tags)
am\-history(1)
Search the command history (am and shell commands)
.TP
am\-playbook(1)
Replay a saved playbook step by step with confirmation (issue #51)
.TP
am\-lab(1)
Benchmark: run the same task on several agents and compare (issue #62)
.TP
am\-plugins(1)
List the event plugins and test one (issue #75)
.TP
am\-info(1)
Show detailed information about one agent
.TP
@@ -150,6 +205,9 @@ Display help: the overview, a command, the command list, or a search
am\-version(1)
Display version and build information
.TP
am\-tip(1)
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.TP
am\-config(1)
Manage the configuration file
.TP
@@ -174,4 +232,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.4.3
v0.6.0
+21 -1
View File
@@ -21,6 +21,9 @@ pub struct App {
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
/// Active output language (issue #74); mirrored in i18n::active() so the
/// REPL 'lang' command can switch it at runtime.
lang: Cell<crate::i18n::Lang>,
/// Active output color theme (switchable at runtime with the REPL
/// 'theme <name>' command).
theme: Cell<&'static Theme>,
@@ -84,6 +87,8 @@ impl App {
loaded.config.groups.clone(),
);
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
let lang = crate::i18n::resolve(cli.lang);
crate::i18n::set_lang(lang);
Ok(Self {
cli,
config: loaded.config,
@@ -92,6 +97,7 @@ impl App {
catalog,
state,
log,
lang: Cell::new(lang),
theme: Cell::new(theme),
})
}
@@ -116,6 +122,18 @@ impl App {
self.theme.get()
}
/// Active output language (issue #74).
pub fn lang(&self) -> crate::i18n::Lang {
self.lang.get()
}
/// Switch the active language (REPL 'lang' command); the i18n global is
/// kept in sync so tr()/tr_fmt! follow immediately.
pub fn set_lang(&self, lang: crate::i18n::Lang) {
self.lang.set(lang);
crate::i18n::set_lang(lang);
}
/// Switch the active theme (used by the REPL 'theme <name>' command).
pub fn set_theme(&self, theme: &'static Theme) {
self.theme.set(theme);
@@ -131,11 +149,13 @@ impl App {
}
/// Append an event to the journal; failures are never fatal (logged at
/// verbose level only).
/// verbose level only). The plugins subscribed to the event kind are
/// then fired (issue #75) — non-blocking, never fatal either.
pub fn emit(&self, event: &crate::events::Event) {
if let Err(e) = crate::events::append(&self.events_dir(), event) {
self.log.verbose(&format!("cannot write event journal: {e:#}"));
}
crate::plugins::dispatch(self, event);
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
+438
View File
@@ -0,0 +1,438 @@
//! Automation helpers (axe 6): system service units, scheduled tasks,
//! container commands and health checks. Pure functions here are unit
//! tested; the thin command layers live in commands/service_cmd.rs,
//! commands/schedule_cmd.rs, run_cmd.rs and doctor_cmd.rs.
use crate::config::{ContainerSpec, HealthCheck};
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Am binary path used inside generated units/tasks (quoted).
pub fn am_bin_quoted() -> String {
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "am".to_string());
shell_escape(&exe)
}
fn shell_escape(s: &str) -> String {
if cfg!(windows) {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
// ---------------------------------------------------------------------------
// #55: system service units
// ---------------------------------------------------------------------------
/// systemd user unit for 'am service install <agent> --autostart'.
pub fn systemd_unit(agent: &str, am_bin: &str) -> String {
format!(
"# Generated by 'am service install {agent}' — managed by agent-manager\n\
[Unit]\n\
Description=agent-manager: {agent}\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={am_bin} start {agent} --background --notify\n\
Restart=on-failure\n\
RestartSec=10\n\
\n\
[Install]\n\
WantedBy=default.target\n"
)
}
/// launchd plist for macOS.
pub fn launchd_plist(agent: &str, am_bin: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>Label</key>\n\
\t<string>com.agent-manager.{agent}</string>\n\
\t<key>ProgramArguments</key>\n\
\t<array>\n\
\t\t<string>{am_bin}</string>\n\
\t\t<string>start</string>\n\
\t\t<string>{agent}</string>\n\
\t\t<string>--background</string>\n\
\t\t<string>--notify</string>\n\
\t</array>\n\
\t<key>RunAtLoad</key>\n\
\t<true/>\n\
\t<key>KeepAlive</key>\n\
\t<true/>\n\
</dict>\n\
</plist>\n"
)
}
/// Windows scheduled task XML (on logon, with retry).
pub fn windows_task_xml(agent: &str, am_bin: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
\t<Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>\n\
\t<Principals><Principal id=\"Author\"><LogonType>InteractiveToken</LogonType>\n\
\t<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n\
\t<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
\t<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
\t<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
\t<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>\n\
\t</Settings>\n\
\t<Actions><Exec>\n\
\t<Command>{am_bin}</Command>\n\
\t<Arguments>start {agent} --background --notify</Arguments>\n\
\t</Exec></Actions>\n\
</Task>\n"
)
}
/// Path of the systemd user unit for an agent.
pub fn systemd_unit_path(agent: &str) -> PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
crate::config::home_dir()
.map(|h| h.join(".config"))
.unwrap_or_else(|| PathBuf::from(".config"))
});
base.join("systemd").join("user").join(format!("am-{agent}.service"))
}
/// Path of the launchd plist for an agent.
pub fn launchd_path(agent: &str) -> PathBuf {
crate::config::home_dir()
.map(|h| h.join("Library/LaunchAgents"))
.unwrap_or_else(|| PathBuf::from("."))
.join(format!("com.agent-manager.{agent}.plist"))
}
/// Windows task name for an agent.
pub fn windows_task_name(agent: &str) -> String {
format!("am-{agent}")
}
// ---------------------------------------------------------------------------
// #56: scheduled tasks
// ---------------------------------------------------------------------------
/// HH:MM at which a "daily" schedule runs (default 08:00). Parsed leniently.
pub fn parse_hhmm(at: &str) -> (u8, u8) {
let parts: Vec<&str> = at.split(':').collect();
let h: u8 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(8);
let m: u8 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
(h.min(23), m.min(59))
}
/// Crontab line for a daily schedule (Linux/macOS).
pub fn crontab_line(at: &str, am_bin: &str, schedule_id: &str) -> String {
let (h, m) = parse_hhmm(at);
format!("{m} {h} * * * {am_bin} schedule-run {schedule_id}")
}
/// schtasks command registering a daily task (Windows).
pub fn schtasks_daily(at: &str, am_bin: &str, schedule_id: &str, task_name: &str) -> Command {
let (h, m) = parse_hhmm(at);
let mut c = Command::new("schtasks");
c.args([
"/create",
"/tn",
task_name,
"/sc",
"daily",
"/st",
&format!("{h:02}:{m:02}"),
"/tr",
&format!("\"{am_bin}\" schedule-run {schedule_id}"),
"/f",
]);
c
}
/// The command that actually runs a schedule: am schedule-run <id>.
pub fn schedule_run_command(app: &crate::app::App, entry: &crate::state::ScheduleEntry) -> Result<i32> {
// Re-dispatch through the CLI so hooks/events/state stay consistent.
let args: Vec<String> = entry
.command
.iter()
.map(|s| s.to_string())
.collect();
let mut full = vec!["am".to_string()];
if let Some(cfg) = &app.config_origin {
full.push("--config".to_string());
full.push(cfg.to_string_lossy().to_string());
}
full.extend(args);
let status = Command::new(std::env::current_exe()?)
.args(&full[1..])
.status()?;
Ok(status.code().unwrap_or(1))
}
// ---------------------------------------------------------------------------
// #57: health checks
// ---------------------------------------------------------------------------
/// Wait for an agent health check (issue #57). Returns Ok when the check
/// passes within the timeout, Err otherwise. Never probes when the agent
/// has no healthcheck configured.
pub fn wait_health(agent: &str, hc: Option<&HealthCheck>, log: &crate::output::Logger) -> Result<()> {
let Some(hc) = hc else {
return Ok(());
};
let timeout = hc.timeout_secs.unwrap_or(30).max(1);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
let mut attempt = 0;
loop {
attempt += 1;
let ok = healthcheck_once(hc);
if ok {
return Ok(());
}
if std::time::Instant::now() >= deadline {
bail!(
"health check for '{agent}' failed after {timeout}s \
(command: {}, port: {})",
hc.command.as_deref().unwrap_or("-"),
hc.port.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string())
);
}
if attempt == 1 || attempt % 5 == 0 {
log.verbose(&format!(
"waiting for '{agent}' health (attempt {attempt})..."
));
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
/// One health check probe: command exit 0, or TCP connect on the port.
pub fn healthcheck_once(hc: &HealthCheck) -> bool {
if let Some(cmd) = &hc.command {
let ok = Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return false;
}
}
if let Some(port) = hc.port {
if TcpStream::connect(("127.0.0.1", port)).is_err() {
return false;
}
}
true
}
// ---------------------------------------------------------------------------
// #58: container profiles
// ---------------------------------------------------------------------------
/// Detect a container runtime: podman preferred, docker fallback.
pub fn detect_runtime() -> Option<String> {
for bin in ["podman", "docker"] {
if Command::new(bin)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return Some(bin.to_string());
}
}
None
}
/// Build the container run command for an agent (issue #58): mounts the
/// state dir and log dir, applies the profile volumes/network/env, runs
/// the agent's run command in the image.
pub fn container_command(
runtime: &str,
agent: &str,
spec: &ContainerSpec,
state_dir: &Path,
log_dir: &Path,
) -> Result<Vec<String>> {
if spec.image.trim().is_empty() {
bail!("agent '{agent}': container profile requires an 'image'");
}
let mut cmd = vec![
runtime.to_string(),
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
format!("am-{agent}"),
];
// State + logs mounts so sessions/history keep working.
cmd.push("-v".to_string());
cmd.push(format!("{}:/data", state_dir.to_string_lossy()));
cmd.push("-v".to_string());
cmd.push(format!("{}:/logs", log_dir.to_string_lossy()));
for v in &spec.volumes {
cmd.push("-v".to_string());
cmd.push(v.clone());
}
match &spec.network {
Some(n) => {
cmd.push("--network".to_string());
cmd.push(n.clone());
}
None => {
cmd.push("--network".to_string());
cmd.push(
if cfg!(target_os = "linux") {
"host".to_string()
} else {
"bridge".to_string()
},
);
}
}
for (k, v) in &spec.env {
cmd.push("-e".to_string());
cmd.push(format!("{k}={v}"));
}
cmd.push(spec.image.clone());
Ok(cmd)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systemd_unit_is_valid_and_referenced() {
let unit = systemd_unit("claude-code", "/usr/local/bin/am");
assert!(unit.contains("[Unit]"));
assert!(unit.contains("ExecStart=/usr/local/bin/am start claude-code --background --notify"));
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("[Install]"));
}
#[test]
fn launchd_plist_is_valid_xml_shape() {
let plist = launchd_plist("aider", "/opt/homebrew/bin/am");
assert!(plist.contains("com.agent-manager.aider"));
assert!(plist.contains("<string>/opt/homebrew/bin/am</string>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
}
#[test]
fn windows_task_xml_has_action() {
let xml = windows_task_xml("codex", "C:\\bin\\am.exe");
assert!(xml.contains("LogonTrigger"));
assert!(xml.contains("am.exe"));
assert!(xml.contains("start codex --background --notify"));
assert!(xml.contains("RestartOnFailure"));
}
#[test]
fn crontab_line_formats_time_and_id() {
let line = crontab_line("22:05", "/usr/bin/am", "sched-1");
assert_eq!(line, "5 22 * * * /usr/bin/am schedule-run sched-1");
let line = crontab_line("", "/usr/bin/am", "sched-2");
assert_eq!(line, "0 8 * * * /usr/bin/am schedule-run sched-2");
}
#[test]
fn parse_hhmm_is_lenient() {
assert_eq!(parse_hhmm("09:30"), (9, 30));
assert_eq!(parse_hhmm("garbage"), (8, 0));
assert_eq!(parse_hhmm("25:99"), (23, 59));
}
#[test]
fn healthcheck_command_exit_0_is_healthy() {
let hc = HealthCheck {
command: Some("true".to_string()),
port: None,
timeout_secs: Some(5),
};
assert!(healthcheck_once(&hc));
let bad = HealthCheck {
command: Some("false".to_string()),
port: None,
timeout_secs: Some(5),
};
assert!(!healthcheck_once(&bad));
}
#[test]
fn healthcheck_without_config_skips() {
let app = test_app();
assert!(wait_health("x", None, &app.log).is_ok());
}
fn test_app() -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
app.paths.state_file = dir.path().join("state.json");
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
app
}
#[test]
fn container_command_mounts_and_env() {
let spec = ContainerSpec {
image: "node:20".to_string(),
volumes: vec!["/host/work:/work:ro".to_string()],
network: None,
env: BTreeMap::from([("NODE_ENV".to_string(), "production".to_string())]),
};
let cmd = container_command(
"docker",
"my-agent",
&spec,
Path::new("/state"),
Path::new("/logs"),
)
.unwrap();
assert_eq!(cmd[0], "docker");
assert!(cmd.contains(&"run".to_string()));
assert!(cmd.contains(&"-v".to_string()));
assert!(cmd.contains(&"/state:/data".to_string()));
assert!(cmd.contains(&"/logs:/logs".to_string()));
assert!(cmd.contains(&"--network".to_string()));
assert!(cmd.contains(&"-e".to_string()));
assert!(cmd.contains(&"NODE_ENV=production".to_string()));
assert_eq!(cmd.last().unwrap(), "node:20");
// Empty image fails clearly.
let bad = ContainerSpec { image: String::new(), ..Default::default() };
assert!(container_command("docker", "a", &bad, Path::new("/s"), Path::new("/l")).is_err());
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Pre-update backups (issue #64, axe 8): a snapshot of the install
//! directory, the state database and the user config is taken before every
//! real update, kept under <state>/backups/<timestamp>/, and restored by
//! 'am update --rollback <point>'. Retention is bounded by
//! settings.backups_keep (default 5).
use crate::app::App;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Default number of backups to keep (settings.backups_keep default).
pub const DEFAULT_KEEP: u32 = 5;
/// Manifest of one backup point (JSON file inside the backup directory).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupInfo {
/// Backup id = the directory name (RFC 3339 timestamp).
pub id: String,
/// RFC 3339 creation timestamp.
pub created_at: String,
/// Reason: "update:<agent>" or "update:--all".
pub reason: String,
/// Agents whose install directories were snapshotted (empty = all).
pub agents: Vec<String>,
/// True when the state database was snapshotted.
pub state: bool,
/// True when the user config file was snapshotted.
pub config: bool,
}
impl BackupInfo {
/// Load the manifest of one backup directory.
fn load(dir: &Path) -> Option<BackupInfo> {
let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
serde_json::from_str(&text).ok()
}
}
/// Directory holding every backup point. Lives next to the state database
/// so tests and --config overrides stay fully sandboxed.
pub fn backups_dir(app: &App) -> PathBuf {
app.paths
.state_file
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("backups")
}
/// Take a snapshot before an update. `agents` lists the agents being
/// updated; an empty list means "the whole install directory".
pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result<BackupInfo> {
let now = crate::installers::now_rfc3339();
// Directory names must be sortable and filesystem-safe: the RFC 3339
// timestamp with colons replaced by dashes.
let id = now.replace(':', "-").replace('Z', "").replace('+', "_");
let root = backups_dir(app).join(&id);
std::fs::create_dir_all(&root)
.with_context(|| format!("cannot create backup directory {}", root.display()))?;
let mut info = BackupInfo {
id: id.clone(),
created_at: now,
reason: reason.to_string(),
agents: agents.to_vec(),
state: false,
config: false,
};
// 1. State database.
if let Ok(sf) = app.state.load() {
let text = serde_json::to_string_pretty(&sf)?;
std::fs::write(root.join("state.json"), text)
.with_context(|| "cannot snapshot state.json".to_string())?;
info.state = true;
}
// 2. User config file (the active origin when it is a user file).
if let Some(origin) = &app.config_origin {
if origin.is_file() {
std::fs::copy(origin, root.join("config.yaml")).with_context(|| {
format!("cannot snapshot user config {}", origin.display())
})?;
info.config = true;
}
}
// 3. Install directories of the updated agents (or everything).
let install_root = &app.paths.install_dir;
let dest_root = root.join("install");
std::fs::create_dir_all(&dest_root)?;
if agents.is_empty() {
copy_tree(install_root, &dest_root)
.with_context(|| format!("cannot snapshot {}", install_root.display()))?;
} else {
for name in agents {
let entry = app.state.get(name).ok().flatten();
if let Some(e) = entry {
let src = PathBuf::from(&e.install_dir);
if src.is_dir() {
let dest = dest_root.join(&e.name);
copy_tree(&src, &dest).with_context(|| {
format!("cannot snapshot {} for {name}", src.display())
})?;
}
}
// Also snapshot the shim binaries (bin/<name>).
let shim = app.paths.bin_dir.join(name);
if shim.exists() {
copy_tree(&shim, &dest_root.join("bin").join(name))?;
}
}
}
// Write the manifest last: a backup without a manifest is invalid.
let manifest = serde_json::to_string_pretty(&info)?;
std::fs::write(root.join("manifest.json"), manifest)
.with_context(|| "cannot write backup manifest".to_string())?;
prune(app)?;
Ok(info)
}
/// List every backup point, oldest first.
pub fn list_backups(app: &App) -> Vec<BackupInfo> {
let mut out: Vec<BackupInfo> = Vec::new();
if let Ok(entries) = std::fs::read_dir(backups_dir(app)) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(info) = BackupInfo::load(&path) {
out.push(info);
}
}
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
/// Resolve a rollback point: "latest"/empty = most recent backup,
/// otherwise the backup id (prefix matching allowed).
pub fn resolve_point(app: &App, point: Option<&str>) -> Result<BackupInfo> {
let all = list_backups(app);
if all.is_empty() {
bail!("no backup found — nothing to roll back to");
}
match point {
None | Some("latest") => all
.last()
.cloned()
.ok_or_else(|| anyhow!("no backup found")),
Some(p) => {
let exact = all.iter().find(|b| b.id == p);
if let Some(b) = exact {
return Ok(b.clone());
}
let prefix: Vec<BackupInfo> = all
.iter()
.filter(|b| b.id.starts_with(p))
.cloned()
.collect();
match prefix.len() {
0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"),
1 => Ok(prefix[0].clone()),
_ => bail!(
"backup prefix '{p}' is ambiguous ({} matches: {})",
prefix.len(),
prefix
.iter()
.map(|b| b.id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
}
}
/// Restore a backup point over the live install: state, user config and
/// install directories. The agent processes of the restored agents are
/// stopped first (they would hold the old binaries open).
pub fn restore_backup(app: &App, point: Option<&str>) -> Result<BackupInfo> {
let info = resolve_point(app, point)?;
let root = backups_dir(app).join(&info.id);
if !root.join("manifest.json").is_file() {
bail!("backup {} is corrupted (no manifest)", info.id);
}
// Stop the agents being restored (only managed ones we know).
let names: Vec<String> = if info.agents.is_empty() {
app.state
.all()?
.keys()
.cloned()
.collect()
} else {
info.agents.clone()
};
for name in &names {
if let Ok(Some(entry)) = app.state.get(name) {
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log
.info(&format!("stopping {name} (pid {pid}) before rollback"));
let _ = crate::commands::run_cmd::stop(app, name, false, None);
}
}
}
}
// 1. Restore the state database.
if info.state {
let src = root.join("state.json");
if src.is_file() {
std::fs::copy(&src, app.state.path())
.with_context(|| "cannot restore state.json".to_string())?;
}
}
// 2. Restore the user config.
if info.config {
if let Some(origin) = &app.config_origin {
if let Some(parent) = origin.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(root.join("config.yaml"), origin)
.with_context(|| format!("cannot restore {}", origin.display()))?;
}
}
// 3. Restore the install directories.
let src_root = root.join("install");
if src_root.is_dir() {
if info.agents.is_empty() {
if app.paths.install_dir.exists() {
std::fs::remove_dir_all(&app.paths.install_dir)
.with_context(|| "cannot clear the install directory".to_string())?;
}
copy_tree(&src_root, &app.paths.install_dir)?;
} else {
for name in &info.agents {
let src = src_root.join(name);
if !src.exists() {
continue;
}
// Remove the agent's current install dir (known from the
// restored state) and copy the snapshot back.
if let Ok(Some(entry)) = app.state.get(name) {
let cur = PathBuf::from(&entry.install_dir);
if cur.is_dir() {
let _ = std::fs::remove_dir_all(&cur);
}
}
let dest = app.paths.install_dir.join(name);
copy_tree(&src, &dest)?;
}
// Restore the shims.
let bin_src = src_root.join("bin");
if bin_src.is_dir() {
copy_tree(&bin_src, &app.paths.bin_dir)?;
}
}
}
Ok(info)
}
/// Enforce settings.backups_keep: delete the oldest backups beyond the cap.
pub fn prune(app: &App) -> Result<()> {
let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize;
let all = list_backups(app);
if all.len() <= keep {
return Ok(());
}
for old in all.iter().take(all.len() - keep) {
let dir = backups_dir(app).join(&old.id);
if dir.is_dir() {
let _ = std::fs::remove_dir_all(&dir);
}
}
Ok(())
}
/// Recursively copy a directory tree (files and directories only; symlinks
/// are copied as-is when possible).
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
return Ok(());
}
let meta = std::fs::metadata(src)?;
if meta.is_file() {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dest)?;
return Ok(());
}
std::fs::create_dir_all(dest)?;
let mut entries: Vec<_> = std::fs::read_dir(src)?
.flatten()
.map(|e| e.path())
.collect();
entries.sort();
for child in entries {
let name = child
.file_name()
.ok_or_else(|| anyhow!("invalid path {}", child.display()))?;
copy_tree(&child, &dest.join(name))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::state::StateFile;
use clap::Parser;
use std::io::Write;
fn test_app(tag: &str) -> App {
let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"--yes",
"--no-color",
]);
let mut app = App::from_cli(cli).expect("app builds");
// Point the state/install dirs into the temp dir so nothing touches
// the real user data.
let state_file = dir.join("state.json");
let install = dir.join("agents");
std::fs::create_dir_all(&install).unwrap();
app.paths.state_file = state_file.clone();
app.paths.install_dir = install.clone();
app.paths.bin_dir = install.join("bin");
app.state = crate::state::StateStore::new(state_file);
app.config_origin = Some(cfg);
// Fresh backups dir for this test run.
let _ = std::fs::remove_dir_all(backups_dir(&app));
app
}
fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf {
let dir = app.paths.install_dir.join(name);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("agent.txt");
let mut h = std::fs::File::create(&f).unwrap();
h.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn backup_create_list_restore_roundtrip() {
let app = test_app("roundtrip");
write_agent_file(&app, "alpha", "v1");
write_agent_file(&app, "beta", "b1");
// Simulate a managed entry for alpha.
let mut sf = StateFile::default();
sf.installed.insert(
"alpha".to_string(),
crate::state::InstalledEntry {
name: "alpha".to_string(),
version: Some("1.0.0".to_string()),
method: "binary".to_string(),
install_dir: app.paths.install_dir.join("alpha").display().to_string(),
bins: vec![],
run: "alpha".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
},
);
app.state.save(&sf).unwrap();
let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap();
assert!(info.state && info.config);
// Corrupt the live install + state.
write_agent_file(&app, "alpha", "BROKEN");
let mut bad = StateFile::default();
bad.sessions_count = 999;
app.state.save(&bad).unwrap();
restore_backup(&app, Some(&info.id)).unwrap();
let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap();
assert_eq!(text, "v1", "install dir restored");
let sf = app.state.load().unwrap();
assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0"));
assert_eq!(sf.sessions_count, 0, "state restored");
}
#[test]
fn resolve_point_prefix_and_latest() {
let app = test_app("resolve");
create_backup(&app, "update:a", &[]).unwrap();
let info = resolve_point(&app, None).unwrap();
assert_eq!(info.agents, Vec::<String>::new());
let prefix = &info.id[..info.id.len().min(10)];
let by_prefix = resolve_point(&app, Some(prefix)).unwrap();
assert_eq!(by_prefix.id, info.id);
}
#[test]
fn prune_keeps_only_backups_keep() {
let mut app = test_app("prune");
app.config.settings.backups_keep = Some(1);
create_backup(&app, "update:one", &[]).unwrap();
create_backup(&app, "update:two", &[]).unwrap();
let all = list_backups(&app);
assert_eq!(all.len(), 1, "only the newest backup survives");
}
}
+2 -1
View File
@@ -133,7 +133,8 @@ impl Catalog {
/// Score a keyword against one haystack field: 100 for a full hit,
/// then subsequence and typo bonuses; 0 = no relation at all.
fn score_keyword(keyword: &str, hay: &str) -> u32 {
/// Public so `am suggest` can reuse the exact same ranking (issue #61).
pub fn score_keyword(keyword: &str, hay: &str) -> u32 {
if keyword.is_empty() {
return 0;
}
+303
View File
@@ -0,0 +1,303 @@
//! Remote catalogs (issues #60 #67, axes 7 & 9): fetching a catalog from
//! an http(s) URL, validating its format, and caching it on disk the
//! probe.rs way (keyed by URL + timestamp, TTL-bounded). Used both by
//! 'am catalog update/add' and by config `include:` entries pointing at
//! URLs.
//!
//! Rules:
//! * only http, https and file:// URLs are accepted (download::validate_url);
//! * a cached copy is served while it is fresh; on network failure the
//! stale cache is used with a warning (offline support);
//! * a malformed catalog is an error, never silently skipped.
use crate::config::{self, Config};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Default time-to-live of a cached remote catalog, in seconds (1 h).
pub const DEFAULT_TTL_SECS: u64 = 3600;
/// Base URL of the official catalog (the agent-manager repo on Gitea).
pub const DEFAULT_CATALOG_URL: &str =
"https://git.dracodev.net/Projets/agent-manager/raw/branch/main/config.yaml";
/// One cached catalog: the raw text, the URL it came from, and the fetch
/// time (seconds since the epoch).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedCatalog {
pub url: String,
pub fetched_at: u64,
pub text: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CatalogCache {
pub entries: BTreeMap<String, CachedCatalog>,
}
impl CatalogCache {
fn load(state_dir: &Path) -> Self {
std::fs::read_to_string(state_dir.join("catalog-cache.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn save(&self, state_dir: &Path) {
if let Ok(text) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(state_dir.join("catalog-cache.json"), text);
}
}
}
/// Fetch the text of a catalog URL, using and refreshing the on-disk cache.
/// `force` bypasses the TTL (used by 'am catalog update').
pub fn fetch(app: &crate::app::App, url: &str, force: bool) -> Result<String> {
let ttl = app
.config
.settings
.catalog_cache_ttl_secs
.unwrap_or(DEFAULT_TTL_SECS);
fetch_with_cache(&app.events_dir(), url, force, ttl, Some(&app.log))
}
/// Fetch a catalog URL during config loading (no App available yet): the
/// cache lives in the state directory and the TTL is the default one.
pub fn fetch_for_config(state_dir: &Path, url: &str) -> Result<String> {
fetch_with_cache(state_dir, url, false, DEFAULT_TTL_SECS, None)
}
/// Shared fetch + cache logic.
fn fetch_with_cache(
state_dir: &Path,
url: &str,
force: bool,
ttl: u64,
log: Option<&crate::output::Logger>,
) -> Result<String> {
crate::download::validate_url(url)?;
let mut cache = CatalogCache::load(state_dir);
let now = now_secs();
// Fresh cache hit: no network.
if !force {
if let Some(entry) = cache.entries.get(url) {
if now.saturating_sub(entry.fetched_at) < ttl {
return Ok(entry.text.clone());
}
}
}
// Try the network; fall back to the stale cache on failure.
let fresh = fetch_url_text(url);
match fresh {
Ok(text) => {
cache.entries.insert(
url.to_string(),
CachedCatalog {
url: url.to_string(),
fetched_at: now,
text: text.clone(),
},
);
cache.save(state_dir);
Ok(text)
}
Err(net_err) => {
if let Some(entry) = cache.entries.get(url) {
if let Some(log) = log {
log.warn(&format!(
"cannot refresh catalog {url} ({net_err:#}) — using the cached copy from {}",
format_ts(entry.fetched_at)
));
}
Ok(entry.text.clone())
} else {
Err(net_err)
}
}
}
}
/// Fetch the raw text of an http(s) or file:// URL (no cache).
fn fetch_url_text(url: &str) -> Result<String> {
if let Some(local) = url.strip_prefix("file://") {
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
let local = if cfg!(windows) && local.starts_with('/') {
local.strip_prefix('/').unwrap_or(local)
} else {
local
};
let p = PathBuf::from(local);
return std::fs::read_to_string(&p)
.with_context(|| format!("cannot read catalog file {}", p.display()));
}
let resp = ureq::get(url)
.set("User-Agent", crate::download::USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.call()
.map_err(|e| match e {
ureq::Error::Status(code, r) => anyhow!(
"HTTP {code} {} while fetching {url}",
r.status_text()
),
other => anyhow!("cannot fetch {url}: {other}"),
})?;
resp.into_string()
.with_context(|| format!("reading the catalog body of {url}"))
}
/// Parse and validate a catalog YAML text; returns the parsed Config.
pub fn parse_catalog(text: &str) -> Result<Config> {
let cfg: Config = serde_yaml::from_str(text)
.map_err(|e| anyhow!("the remote catalog is not a valid config: {e}"))?;
let problems = config::validate(&cfg);
if !problems.is_empty() {
bail!(
"the remote catalog is invalid:\n - {}",
problems.join("\n - ")
);
}
Ok(cfg)
}
/// Diff between the local effective catalog and a remote one: agent names
/// that are new, removed, or changed (run command differs).
pub struct CatalogDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<(String, String, String)>, // (name, local run, remote run)
}
pub fn diff_catalogs(local: &Config, remote: &Config) -> CatalogDiff {
let local_names: BTreeMap<&str, &str> = local
.agents
.iter()
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
.collect();
let remote_names: BTreeMap<&str, &str> = remote
.agents
.iter()
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
.collect();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (name, run) in &remote_names {
match local_names.get(name) {
None => added.push((*name).to_string()),
Some(local_run) if local_run != run => {
changed.push(((*name).to_string(), (*local_run).to_string(), (*run).to_string()));
}
_ => {}
}
}
for name in local_names.keys() {
if !remote_names.contains_key(name) {
removed.push((*name).to_string());
}
}
added.sort();
removed.sort();
changed.sort();
CatalogDiff {
added,
removed,
changed,
}
}
/// The official catalog URL: the configured value, or the Gitea default.
pub fn official_url(app: &crate::app::App) -> String {
app.config
.settings
.catalog_url
.clone()
.unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn format_ts(secs: u64) -> String {
let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
dt.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown date".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str = "version: \"1.0\"\nagents:\n - name: alpha\n run: alpha\n install: { type: binary, url: \"https://x/a\" }\n";
#[test]
fn parses_and_validates_a_remote_catalog() {
let cfg = parse_catalog(VALID).unwrap();
assert_eq!(cfg.agents.len(), 1);
assert_eq!(cfg.agents[0].name, "alpha");
}
#[test]
fn rejects_malformed_catalogs() {
assert!(parse_catalog("not: [valid").is_err());
// Missing run on an installable agent is invalid.
assert!(parse_catalog(
"version: \"1.0\"\nagents:\n - name: x\n install: { type: npm, package: y }\n"
)
.is_err());
}
#[test]
fn diff_detects_added_removed_changed() {
let local: Config = serde_yaml::from_str(
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: gone, run: gone, install: { type: binary, url: \"https://x/g\" } }\n - { name: tweaked, run: old, install: { type: binary, url: \"https://x/t\" } }\n",
)
.unwrap();
let remote: Config = serde_yaml::from_str(
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: fresh, run: fresh, install: { type: binary, url: \"https://x/f\" } }\n - { name: tweaked, run: new, install: { type: binary, url: \"https://x/t\" } }\n",
)
.unwrap();
let d = diff_catalogs(&local, &remote);
assert_eq!(d.added, vec!["fresh".to_string()]);
assert_eq!(d.removed, vec!["gone".to_string()]);
assert_eq!(d.changed.len(), 1);
assert_eq!(d.changed[0].0, "tweaked");
assert_eq!(d.changed[0].1, "old");
assert_eq!(d.changed[0].2, "new");
}
#[test]
fn file_url_fetch_works_offline() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cat.yaml");
std::fs::write(&p, VALID).unwrap();
let url = format!("file://{}", p.display());
let text = fetch_url_text(&url).unwrap();
assert!(text.contains("alpha"));
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut cache = CatalogCache::default();
cache.entries.insert(
"https://x".to_string(),
CachedCatalog {
url: "https://x".to_string(),
fetched_at: 42,
text: "body".to_string(),
},
);
cache.save(dir.path());
let back = CatalogCache::load(dir.path());
assert_eq!(back.entries["https://x"].text, "body");
}
}
+240 -1
View File
@@ -54,6 +54,10 @@ pub struct Cli {
#[arg(long, global = true, value_name = "THEME")]
pub theme: Option<String>,
/// Output language: fr (default) or en (issue #74)
#[arg(long, global = true, value_enum, value_name = "LANG")]
pub lang: Option<crate::i18n::LangArg>,
#[command(subcommand)]
pub command: Option<Command>,
}
@@ -157,6 +161,11 @@ pub enum Command {
#[arg(long, value_name = "DIR")]
output: Option<PathBuf>,
},
/// Set or show the active color theme (persisted for the next run)
Theme {
/// Theme name (omit to list available themes)
name: Option<String>,
},
/// Internal: list agent names for shell completions (hidden)
#[command(name = "_agents", hide = true)]
Agents {
@@ -170,6 +179,25 @@ pub enum Command {
/// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)]
Secret(SecretCmd),
/// Audit the configuration: who changed what, when (issue #63)
Audit,
/// Register an agent as a system service (issue #55)
#[command(subcommand)]
Service(ServiceCmd),
/// Schedule am commands (issue #56)
#[command(subcommand)]
Schedule(ScheduleCmd),
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
Models(ModelsArgs),
/// Manage remote catalogs: update the official one, add external ones
#[command(subcommand)]
Catalog(CatalogCmd),
/// Suggest agents matching a query, boosted by real usage (issue #61)
Suggest {
/// Free-form request ("un agent pour du Python")
#[arg(value_name = "QUERY", num_args = 1.., required = true)]
words: Vec<String>,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -215,6 +243,15 @@ pub enum Command {
/// Resume a finished session: relaunch the agent with its recorded arguments
#[arg(long, value_name = "ID")]
resume: Option<String>,
/// Export one session (metadata, commands, log excerpt) to a JSON file
#[arg(long, value_name = "ID")]
export: Option<String>,
/// Destination of the export (default: ./am-session-<ID>.json)
#[arg(long, value_name = "FILE")]
output: Option<String>,
/// Purge sessions finished more than N days ago (settings default 90)
#[arg(long, value_name = "DAYS")]
retention: Option<u64>,
},
/// Show usage statistics computed from the event journal
Stats {
@@ -223,6 +260,38 @@ pub enum Command {
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
/// Show token usage and estimated spend per agent (issue #49)
#[arg(long, action = ArgAction::SetTrue)]
costs: bool,
},
/// Real-time monitor of the managed processes (issue #50)
Monitor {
/// Refresh interval in seconds (default 2)
#[arg(long, value_name = "SECONDS")]
interval: Option<u64>,
/// Print one JSON document per tick instead of the TUI
#[arg(long, action = ArgAction::SetTrue)]
json: bool,
},
/// Local read-only web dashboard with charts (issue #54)
Web(WebArgs),
/// Push the state into the configured git repository (issue #66)
Sync {
/// Commit message (default: "am sync — state update")
#[arg(long, value_name = "MESSAGE")]
message: Option<String>,
},
/// Transfer the installation and state to another machine (issue #68)
Migrate {
/// Export the bundle (default) or import one
#[arg(long, action = ArgAction::SetTrue)]
export: bool,
/// Bundle path for export (default agent-manager-migrate.amx)
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
/// Bundle path to import
#[arg(value_name = "BUNDLE")]
bundle: Option<PathBuf>,
},
/// Show the most used agents (top 10)
Top {
@@ -304,6 +373,10 @@ pub enum Command {
/// Update every installed managed agent
#[arg(long)]
all: bool,
/// Roll back to a pre-update backup instead of updating
/// ("latest" or a backup id; "--rollback list" shows them)
#[arg(long, num_args = 0..=1, default_missing_value = "latest")]
rollback: Option<String>,
},
/// Search the catalog by keyword (name, description, category, tags)
Search {
@@ -340,7 +413,26 @@ pub enum Command {
/// Re-run the Nth most recent entry (with confirmation)
#[arg(long, value_name = "N")]
rerun: Option<usize>,
/// Range to export as a playbook: "12..25" (1-based, newest first)
#[arg(value_name = "RANGE")]
range: Option<String>,
/// Export the range as a playbook YAML file (issue #51)
#[arg(long, value_name = "FILE")]
save: Option<PathBuf>,
},
/// Replay a saved playbook step by step with confirmation (issue #51)
Playbook {
/// Playbook file (state_dir/playbooks/ when a bare name is given)
#[arg(value_name = "FILE")]
path: PathBuf,
/// Variable values, repeatable: --var name=value
#[arg(long, value_name = "NAME=VALUE")]
var: Vec<String>,
},
/// Benchmark: run the same task on several agents and compare (issue #62)
Lab(LabArgs),
/// List the event plugins and test one (issue #75)
Plugins(PluginsArgs),
/// Show detailed information about one agent
Info {
/// Agent name or alias
@@ -351,6 +443,10 @@ pub enum Command {
/// Overwrite an existing agent-manager.yaml
#[arg(long)]
force: bool,
/// Use a template (web, python, rust, cli) instead of stack detection;
/// "list" shows the available templates
#[arg(long, value_name = "TEMPLATE")]
template: Option<String>,
},
/// Display help: the overview, a command, the command list, or a search
Help {
@@ -362,6 +458,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),
@@ -370,11 +473,20 @@ pub enum Command {
/// Attempt to repair problems (create directories, fix the state file)
#[arg(long)]
fix: bool,
/// Re-run the checks every N seconds, alerting on failure (issue #59)
#[arg(long, value_name = "SECONDS")]
watch: Option<u64>,
},
/// Run the agent command directly with the given arguments (no process management)
Run {
/// Agent name or alias
agent: String,
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Run the agent inside a container (issue #58)
#[arg(long)]
container: bool,
/// Arguments passed through to the agent command
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
args: Vec<std::ffi::OsString>,
@@ -440,6 +552,86 @@ pub struct StartArgs {
/// Apply an environment profile (env + args, defined in the config)
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Start every member of a group simultaneously (issue #57)
#[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool,
/// Run the agent inside a container (issue #58)
#[arg(long, action = ArgAction::SetTrue)]
pub container: bool,
}
/// Arguments of the models command: inventory by default, prune with --prune.
#[derive(Args, Debug, Clone, Default)]
pub struct ModelsArgs {
/// Purge unused models instead of listing them
#[arg(long)]
pub prune: bool,
/// Only list what would be pruned, delete nothing
#[arg(long, conflicts_with = "yes")]
pub dry_run: bool,
/// Answer yes to the deletion prompt
#[arg(long)]
pub yes: bool,
/// Consider a model unused after N days (default: 30)
#[arg(long, value_name = "DAYS")]
pub days: Option<u64>,
}
/// Arguments of the lab command (issue #62).
#[derive(Args, Debug, Clone, Default)]
pub struct LabArgs {
/// Comma-separated agent names (or aliases) to benchmark
#[arg(long, value_name = "AGENTS", required_unless_present = "list")]
pub agents: Option<String>,
/// Task file (a bare name resolves under state_dir/lab/, .yaml appended)
#[arg(long, value_name = "FILE", required_unless_present = "list")]
pub task: Option<PathBuf>,
/// Run every agent concurrently instead of one after another
#[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool,
/// Override the task timeout in seconds
#[arg(long, value_name = "SECS")]
pub timeout: Option<u64>,
/// List the task files of the lab directory
#[arg(long, action = ArgAction::SetTrue)]
pub list: bool,
}
/// Arguments of the plugins command (issue #75).
#[derive(Args, Debug, Clone, Default)]
pub struct PluginsArgs {
/// Run one plugin against a synthetic event and print its response
#[arg(long, value_name = "NAME")]
pub test: Option<String>,
}
/// Arguments of the web command (issue #54).
#[derive(Args, Debug, Clone, Default)]
pub struct WebArgs {
/// Port to listen on (default 7878)
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,
/// Do not open the browser automatically
#[arg(long, action = ArgAction::SetTrue)]
pub no_open: bool,
}
/// Remote catalog subcommands (issues #60 #67).
#[derive(Subcommand, Debug, Clone)]
pub enum CatalogCmd {
/// Fetch the official catalog, show the diff and register it as an include
Update,
/// Register an external catalog (same YAML format) by URL
Add {
/// Catalog URL (http, https or file)
#[arg(value_name = "URL")]
url: String,
},
/// List the registered remote catalogs
List,
}
/// Environment profile subcommands (issue #40).
@@ -454,7 +646,54 @@ pub enum ProfileCmd {
},
}
/// Secret management subcommands.
/// 'am service' subcommands (issue #55).
#[derive(Subcommand, Debug, Clone)]
pub enum ServiceCmd {
/// Install the service unit (systemd / launchd / Task Scheduler)
Install {
/// Agent name
agent: String,
/// Enable autostart (boot/logon)
#[arg(long)]
autostart: bool,
},
/// Remove the service unit
Uninstall {
/// Agent name
agent: String,
},
/// Show whether the service is installed
Status {
/// Agent name
agent: String,
},
}
/// 'am schedule' subcommands (issue #56).
#[derive(Subcommand, Debug, Clone)]
pub enum ScheduleCmd {
/// Add a daily schedule (ex: 'am schedule add update --all')
Add {
/// Command to run, e.g. ["update", "--all"]
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "CMD...")]
command: Vec<String>,
/// Time of day HH:MM (default 08:00)
#[arg(long, value_name = "HH:MM")]
at: Option<String>,
},
/// List the schedules
List,
/// Remove a schedule by id
Remove {
/// Schedule id ('am schedule list' shows them)
id: String,
},
/// Run a schedule now (invoked by the OS task, issue #56)
Run {
/// Schedule id
id: String,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum SecretCmd {
/// Store a secret for an agent
+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()));
+332
View File
@@ -0,0 +1,332 @@
//! audit: who changed what, when (issue #63, axe 8). Config checksums are
//! recorded in state.json; 'am audit' compares them with the current files,
//! cross-references the event journal to date the changes, and flags edits
//! made outside of agent-manager.
use super::*;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use crate::state::ConfigChecksum;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Labels of the tracked config files.
const EMBEDDED: &str = "embedded";
const USER: &str = "user";
const LOCAL: &str = "local";
/// SHA-256 (hex) of a file's content, plus its mtime.
fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(text.as_bytes());
ConfigChecksum {
sha256: format!("{:x}", h.finalize()),
mtime_secs,
recorded_at: crate::installers::now_rfc3339(),
}
}
/// mtime in seconds since epoch.
fn mtime_secs(path: &std::path::Path) -> Option<u64> {
std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
/// The tracked config files: label -> (display path, content).
fn tracked_files(app: &App) -> Vec<(&'static str, String, Option<u64>)> {
let mut out = Vec::new();
// Embedded default catalog: content is baked into the binary.
out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None));
// User config file.
if let Some(dir) = &app.paths.config_dir {
let p = dir.join(crate::config::CONFIG_FILE_NAME);
if let Ok(text) = std::fs::read_to_string(&p) {
out.push((USER, text, mtime_secs(&p)));
}
}
// Local project config (agent-manager.yaml in the current directory).
let local = PathBuf::from(crate::config::CONFIG_FILE_NAME);
if let Ok(text) = std::fs::read_to_string(&local) {
out.push((LOCAL, text, mtime_secs(&local)));
}
out
}
/// Kinds of events that represent a modification of the configuration or
/// the fleet (they explain a checksum change).
fn is_mutating_kind(k: &EventKind) -> bool {
matches!(
k,
EventKind::Config
| EventKind::Install
| EventKind::Update
| EventKind::Uninstall
| EventKind::Annotate
| EventKind::Catalog
| EventKind::Model
| EventKind::Backup
| EventKind::Rollback
)
}
/// One audit row: a tracked file and its verdict.
#[derive(serde::Serialize)]
struct FileRow {
file: String,
label: &'static str,
status: &'static str, // ok | new | changed | deleted
changed_at: Option<String>,
via: Option<String>, // "am <kind>" or "manual (outside am)"
}
/// One audited journal event.
#[derive(serde::Serialize)]
struct EventRow {
when: String,
who: String, // "am <kind>"
what: String,
agent: Option<String>,
}
pub fn run(app: &App) -> Result<i32> {
let mut sf = app.state.load()?;
let baseline = std::mem::take(&mut sf.config_checksums);
// 1. Current checksums of every tracked file.
let files = tracked_files(app);
let mut rows: Vec<FileRow> = Vec::new();
let mut next_baseline: BTreeMap<String, ConfigChecksum> = BTreeMap::new();
for (label, text, mtime) in &files {
let current = checksum_of(text, mtime.unwrap_or(0));
let (status, via, changed_at) = match baseline.get(*label) {
None => ("new", Some("baseline recorded now".to_string()), None),
Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None),
Some(prev) => {
let at = mtime
.and_then(|t| {
chrono::DateTime::from_timestamp(t as i64, 0)
.map(|d| d.to_rfc3339())
})
.unwrap_or_else(|| "unknown".to_string());
(status_changed(prev, &current, app, &at), Some(at.clone()), Some(at))
}
};
let display = match *label {
EMBEDDED => "config.yaml (embedded default)".to_string(),
USER => user_config_display(app),
LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME),
_ => label.to_string(),
};
rows.push(FileRow {
file: display,
label,
status,
changed_at,
via,
});
next_baseline.insert(label.to_string(), current);
}
// 2. Cross-reference the journal: recent mutating events.
let events = crate::events::read_events(&app.events_dir(), 200);
let event_rows: Vec<EventRow> = events
.iter()
.filter(|e| is_mutating_kind(&e.kind))
.map(|e| EventRow {
when: e.ts.clone(),
who: format!("am {}", e.kind.as_str()),
what: format!(
"{}{}",
e.reason.clone().unwrap_or_default(),
if e.args.is_empty() {
String::new()
} else {
format!(" ({})", e.args.join(", "))
}
),
agent: e.agent.clone(),
})
.collect();
// 3. Persist the new baseline so the next audit compares against now.
sf.config_checksums = next_baseline;
app.state.save(&sf)?;
if app.json() {
print_json(&serde_json::json!({
"files": rows,
"events": event_rows,
"generated_at": crate::installers::now_rfc3339(),
}));
return Ok(0);
}
// 4. Human-readable report.
println!("config audit — who changed what, when\n");
let mut header = crate::tables::DataTable {
columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()],
rows: Vec::new(),
};
for r in &rows {
header.rows.push(vec![
crate::tables::Cell::str(&r.file),
crate::tables::Cell::str(status_style(r.status)),
crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")),
crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")),
]);
}
let width = crate::output::terminal_width().unwrap_or(120);
print!("{}", header.render_themed(app.theme(), app.color(), width));
println!("\nrecent changes (event journal):");
if event_rows.is_empty() {
println!(" none");
} else {
for e in event_rows.iter().take(15) {
println!(
" {} {} {}{}",
e.when,
e.who,
e.agent.as_deref().unwrap_or("-"),
if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) }
);
}
}
let changed = rows.iter().filter(|r| r.status == "changed").count();
if changed == 0 {
app.log.success("no configuration change detected since the last audit");
} else {
app.log
.warn(&format!("{changed} configuration file(s) changed since the last audit"));
}
Ok(0)
}
fn status_changed(
prev: &ConfigChecksum,
current: &ConfigChecksum,
app: &App,
at: &str,
) -> &'static str {
// A change can be explained by am itself (an event after the baseline).
let events = crate::events::read_events(&app.events_dir(), 100);
let explained = events.iter().any(|e| {
is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str()
});
// 'via' is filled by the caller from this: manual vs am.
let _ = (current, at);
if explained {
"changed"
} else {
"manual"
}
}
fn status_style(s: &str) -> String {
match s {
"ok" => "ok".to_string(),
"new" => "new".to_string(),
"changed" => "CHANGED".to_string(),
"manual" => "MANUAL".to_string(),
_ => s.to_string(),
}
}
fn user_config_display(app: &App) -> String {
app.paths
.config_dir
.as_ref()
.map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string())
.unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::state::{StateFile, StateStore};
use clap::Parser;
use std::io::Write;
fn test_app(tag: &str) -> (App, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"--no-color",
]);
let mut app = App::from_cli(cli).expect("app builds");
let state_file = dir.path().join("state.json");
app.paths.state_file = state_file.clone();
app.paths.config_dir = Some(dir.path().to_path_buf());
app.state = StateStore::new(state_file);
(app, dir)
}
#[test]
fn checksum_changes_when_content_changes() {
let a = checksum_of("version: \"1.0\"\n", 0);
let b = checksum_of("version: \"1.0\"\n# extra\n", 0);
assert_ne!(a.sha256, b.sha256, "different content -> different hash");
assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256);
}
#[test]
fn audit_records_baseline_and_detects_change() {
let (app, dir) = test_app("audit");
let user_cfg = dir.path().join("config.yaml");
// First run: everything is recorded as baseline.
run(&app).unwrap();
let sf = app.state.load().unwrap();
assert!(sf.config_checksums.contains_key(EMBEDDED));
assert!(sf.config_checksums.contains_key(USER));
// Touch the user config outside of am.
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&user_cfg)
.unwrap();
writeln!(f, "# modified by hand").unwrap();
drop(f);
// Second run: the change is flagged (status != ok).
run(&app).unwrap();
let sf2 = app.state.load().unwrap();
// The baseline was re-recorded, so a third run is clean.
run(&app).unwrap();
let sf3 = app.state.load().unwrap();
assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len());
assert!(sf3.config_checksums[USER].sha256.len() == 64);
}
#[test]
fn state_file_roundtrips_checksums() {
let mut sf = StateFile::default();
sf.config_checksums.insert(
"user".to_string(),
ConfigChecksum {
sha256: "ab".repeat(32),
mtime_secs: 42,
recorded_at: "2026-08-18T00:00:00Z".to_string(),
},
);
let text = serde_json::to_string(&sf).unwrap();
let back: StateFile = serde_json::from_str(&text).unwrap();
assert_eq!(back.config_checksums["user"].mtime_secs, 42);
}
}
+166
View File
@@ -0,0 +1,166 @@
//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9).
//! 'am catalog update' refreshes the official catalog and applies it as an
//! include; 'am catalog add <url>' registers any external catalog (same
//! YAML format) the same way. Everything goes through the probe-style
//! cache in catalog_remote so offline runs keep working.
use super::*;
use crate::catalog_remote::{self, CatalogDiff};
use crate::cli::CatalogCmd;
use crate::commands::config_cmd::register_include;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use anyhow::{anyhow, Result};
pub fn run(app: &App, cmd: &CatalogCmd) -> Result<i32> {
match cmd {
CatalogCmd::Update => update(app),
CatalogCmd::Add { url } => add(app, url),
CatalogCmd::List => list(app),
}
}
/// am catalog update: fetch the official catalog, show the diff against the
/// local catalog, ask for confirmation, then register the URL as an include.
fn update(app: &App) -> Result<i32> {
let url = catalog_remote::official_url(app);
app.log.info(&format!("fetching the official catalog: {url}"));
let text = catalog_remote::fetch(app, &url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
let diff = catalog_remote::diff_catalogs(&app.config, &remote);
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"added": diff.added,
"removed": diff.removed,
"changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::<Vec<_>>(),
}));
return Ok(0);
}
print_diff(&diff);
if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
app.log
.success(&format!("catalog is up to date ({} agents)", remote.agents.len()));
return Ok(0);
}
if app.dry_run() {
app.log.dry("would register the official catalog as an include");
return Ok(0);
}
if !app
.confirm(&format!(
"apply this catalog ({} agents, {} changes)?",
remote.agents.len(),
diff.added.len() + diff.removed.len() + diff.changed.len()
))?
{
app.log.info("catalog update cancelled");
return Ok(0);
}
register_include(app, &url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.clone()])
.with_reason("update"),
);
app.log.success(&format!(
"official catalog registered as an include ({url}) — it is effective from the next run"
));
Ok(0)
}
/// am catalog add <url>: validate and register an external catalog.
fn add(app: &App, url: &str) -> Result<i32> {
crate::download::validate_url(url)?;
if config_cmd::user_includes(app).iter().any(|i| i == url) {
app.log
.info(&format!("catalog {url} is already registered as an include"));
return Ok(0);
}
app.log.info(&format!("fetching catalog: {url}"));
// force: 'catalog add' must validate the current content of the URL,
// not a possibly stale cached copy.
let text = catalog_remote::fetch(app, url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"registered": false,
}));
return Ok(0);
}
if remote.agents.is_empty() {
return Err(anyhow!(
"the catalog at {url} defines no agents — refusing to register it"
));
}
if app.dry_run() {
app.log.dry(&format!(
"would register {url} ({:?} agents)",
remote.agents.len()
));
return Ok(0);
}
register_include(app, url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.to_string()])
.with_reason("add"),
);
app.log.success(&format!(
"catalog {url} registered ({} agents) — effective from the next run",
remote.agents.len()
));
Ok(0)
}
/// am catalog list: show the registered remote catalogs (includes).
/// The resolved config consumes its includes at load time, so this reads
/// the user config file directly (like 'config add').
fn list(app: &App) -> Result<i32> {
let urls: Vec<String> = config_cmd::user_includes(app)
.iter()
.filter(|i| {
i.starts_with("http://")
|| i.starts_with("https://")
|| i.starts_with("file://")
})
.cloned()
.collect();
if app.json() {
print_json(&urls);
return Ok(0);
}
if urls.is_empty() {
app.log.info(
"no remote catalog registered — 'am catalog add <url>' or 'am catalog update'",
);
return Ok(0);
}
println!("remote catalogs ({}):", urls.len());
for u in &urls {
println!(" - {u}");
}
Ok(0)
}
fn print_diff(diff: &CatalogDiff) {
if !diff.added.is_empty() {
println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", "));
}
if !diff.removed.is_empty() {
println!(
" - {} removed: {}",
diff.removed.len(),
diff.removed.join(", ")
);
}
if !diff.changed.is_empty() {
println!(" ~ {} changed run command(s):", diff.changed.len());
for (name, from, to) in &diff.changed {
println!(" {name}: '{from}' -> '{to}'");
}
}
}
+39 -9
View File
@@ -217,7 +217,42 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let dest = dir.join(&base);
std::fs::copy(file, &dest)
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
register_include(app, &base)?;
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
user_config_path(app)?.display()
));
Ok(0)
}
/// Read the `include:` entries of the user config file (they are consumed
/// at load time, so the resolved config no longer lists them).
pub fn user_includes(app: &App) -> Vec<String> {
let Some(cfg_path) = user_config_path_if_any(app) else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
return Vec::new();
};
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
return Vec::new();
};
value
.get("include")
.and_then(|i| i.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
/// Add one entry to the `include:` list of the user config file (creating
/// the file with a template when it does not exist). Used by 'config add'
/// (local files) and 'catalog add/update' (remote URLs, issues #60 #67).
pub fn register_include(app: &App, entry: &str) -> Result<()> {
let cfg_path = user_config_path(app)?;
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
@@ -227,7 +262,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(base.clone());
let entry = serde_yaml::Value::String(entry.to_string());
if !seq.contains(&entry) {
seq.push(entry);
}
@@ -236,7 +271,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
base.clone(),
entry.to_string(),
)]),
);
}
@@ -247,16 +282,11 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
_ => {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
format!("version: \"1.0\"\ninclude:\n - {entry}\n"),
)?;
}
}
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
cfg_path.display()
));
Ok(0)
Ok(())
}
#[cfg(test)]
+42 -1
View File
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
use anyhow::Result;
pub fn run(app: &App, fix: bool) -> Result<i32> {
pub fn run(app: &App, fix: bool, watch: Option<u64>) -> Result<i32> {
if let Some(interval) = watch {
return watch_loop(app, interval.max(1));
}
if fix {
let repaired = doctor::fix(app)?;
for r in &repaired {
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
}
Ok(if report.ok() { 0 } else { 1 })
}
/// Issue #59: re-run the doctor checks every N seconds; alert (desktop
/// notification + event) on the first failure, recover silently. Clean
/// Ctrl-C via ctrlc.
fn watch_loop(app: &App, interval: u64) -> Result<i32> {
app.log
.info(&format!("watching every {interval}s (Ctrl-C to stop)"));
let mut was_ok = true;
loop {
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
let ok = report.ok();
if !ok && was_ok {
// First failure: alert.
app.emit(
&Event::now(EventKind::Doctor)
.with_exit_code(1)
.with_reason(format!("{} problem(s) found", report.problems())),
);
app.log.error(&format!(
"{} problem(s) found — 'am doctor --fix' repairs what it can",
report.problems()
));
let _ = crate::process::desktop_notify(
"agent-manager",
&format!("doctor: {} problem(s)", report.problems()),
);
was_ok = false;
} else if ok && !was_ok {
app.log.success("environment recovered");
was_ok = true;
} else if !ok {
app.log
.warn(&format!("still {} problem(s)", report.problems()));
}
std::thread::sleep(std::time::Duration::from_secs(interval));
}
}
+45
View File
@@ -42,6 +42,37 @@ fn rerun_entry(app: &App, n: usize) -> Result<i32> {
}
}
/// Filter history entries; returns them most recent first.
/// Parse "12..25" (inclusive bounds, 1-based) or a single "12".
fn parse_range(r: &str) -> Result<(usize, usize)> {
let r = r.trim();
if r.is_empty() {
anyhow::bail!("empty range — usage: am history <from>..<to> --save <file>");
}
if let Some((a, b)) = r.split_once("..") {
let from = a
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
let to = b
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if from == 0 || to == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((from, to))
} else {
let n = r
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if n == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((n, n))
}
}
/// Filter history entries; returns them most recent first.
pub fn filter(
app: &App,
@@ -96,11 +127,25 @@ pub fn run(
session: Option<&str>,
limit: usize,
rerun: Option<usize>,
range: Option<&str>,
save: Option<&Path>,
) -> Result<i32> {
history::migrate(app)?;
if let Some(n) = rerun {
return rerun_entry(app, n);
}
// Issue #51: 'am history 12..25 --save <file>' exports a playbook.
if let Some(r) = range {
let file = save.ok_or_else(|| anyhow!("--save <file> is required with a range"))?;
let (from, to) = parse_range(r)?;
let (n, path) = crate::playbook::export_range(app, from, to, file)?;
app.log
.success(&format!("playbook saved: {} ({n} step(s))", path.display()));
return Ok(0);
}
if save.is_some() {
anyhow::bail!("usage: am history <from>..<to> --save <file>");
}
let rows = filter(app, kind, cwd, search, failed, session, limit);
if app.json() {
let payload: Vec<serde_json::Value> = rows
+54 -29
View File
@@ -1,5 +1,6 @@
//! init: generate a local agent-manager.yaml for the current directory,
//! based on the detected technology stack.
//! based on the detected technology stack or an explicit template
//! (issue #52: 'am init --template web|python|rust|cli').
use super::*;
use crate::context;
@@ -7,8 +8,9 @@ use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
/// Generate a local config file in dir; refuses to overwrite unless force.
/// Returns the written path.
pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
/// `template` renders a pre-filled config for a stack ('am init --template');
/// None falls back on the automatic stack detection. Returns the written path.
pub fn generate(dir: &Path, force: bool, template: Option<&str>) -> Result<PathBuf> {
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
if target.exists() && !force {
return Err(anyhow!(
@@ -16,34 +18,51 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
target.display()
));
}
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
let stack = if ctx.stack.is_empty() {
"aucune détectée".to_string()
let project = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
let dir_str = dir.to_string_lossy().to_string();
let content = if let Some(t) = template {
crate::templates::render(t, &project, &dir_str)?
} else {
ctx.stack.join(", ")
};
let content = format!(
"# Généré par 'am init' le {date}
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
let stack = if ctx.stack.is_empty() {
crate::i18n::tr("aucune détectée").to_string()
} else {
ctx.stack.join(", ")
};
format!(
"# Généré par 'am init' le {date}
# Pile détectée : {stack}
version: \"1.0\"
groups:
dev: [{members}]
agents: []
",
date = crate::installers::now_rfc3339(),
);
date = crate::installers::now_rfc3339(),
)
};
std::fs::write(&target, content)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
pub fn run(app: &App, force: bool) -> Result<i32> {
pub fn run(app: &App, force: bool, template: Option<&str>) -> Result<i32> {
// 'am init --template list' prints the available templates.
if template == Some("list") {
println!("available templates:");
for (name, desc) in crate::templates::TEMPLATES {
println!(" {name:<8} {desc}");
}
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!(
"would generate {} in the current directory",
@@ -53,16 +72,22 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
}
let cwd = std::env::current_dir().context("cannot determine the current directory")?;
let ctx = context::detect(&cwd);
let path = generate(&cwd, force)?;
app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
"aucune détectée".to_string()
} else {
ctx.stack.join(", ")
}
));
let path = generate(&cwd, force, template)?;
match template {
Some(t) => app.log.success(&format!(
"wrote {} (template: {t})",
path.display()
)),
None => app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
crate::i18n::tr("aucune détectée").to_string()
} else {
ctx.stack.join(", ")
}
)),
}
app.log.info(&format!(
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
));
+101
View File
@@ -0,0 +1,101 @@
//! lab: benchmark the same task on several agents (issue #62).
use super::*;
use crate::cli::LabArgs;
use crate::lab::{self, LabReport};
use anyhow::Result;
pub fn run(app: &App, args: &LabArgs) -> Result<i32> {
if args.list {
let tasks = lab::list_tasks(app);
if app.json() {
let names: Vec<String> = tasks.iter().map(|p| p.display().to_string()).collect();
crate::output::print_json(&names);
return Ok(0);
}
if tasks.is_empty() {
app.log.info(&format!(
"no tasks in {} — write a task.yaml (name, args, timeout) or run 'lab --agents a,b --task hello'",
lab::dir(app).display()
));
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["TASK"]);
for p in tasks {
table.row(vec![p.display().to_string()]);
}
print!("{}", table.render());
return Ok(0);
}
let path = match args.task.as_deref() {
Some(p) => lab::resolve_task(app, p),
None => anyhow::bail!("--task <file> is required"),
};
if !path.exists() {
anyhow::bail!(
"task not found: {} — write it or use 'lab --list' to see the tasks of {}",
path.display(),
lab::dir(app).display()
);
}
let task = lab::load_task(&path)?;
let agents = lab::split_agents(args.agents.as_deref().unwrap_or(""));
if agents.is_empty() {
anyhow::bail!(
"--agents expects at least one agent name, got '{}'",
args.agents.as_deref().unwrap_or("")
);
}
app.log.info(&format!(
"lab '{}' — {} agent(s), {}",
task.name,
agents.len(),
if args.parallel { "parallèle" } else { "séquentiel" }
));
let report = lab::run_benchmark(app, &agents, &task, args.parallel, args.timeout)?;
if app.json() {
crate::output::print_json(&report);
} else {
print_report(app, &report);
}
// Exit code: 0 when every run succeeded, 1 otherwise (CI-friendly).
Ok(if report.runs.iter().all(|r| r.status == "ok") {
0
} else {
1
})
}
fn print_report(app: &App, report: &LabReport) {
let mut table = crate::output::Table::new(vec![
"AGENT", "STATUS", "EXIT", "DURATION", "COST", "OUTPUT",
]);
for r in &report.runs {
table.row(vec![
r.agent.clone(),
r.status.clone(),
r.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "-".into()),
format!("{:.2}s", r.duration_ms as f64 / 1000.0),
r.cost_usd
.map(|c| format!("${c:.4}"))
.unwrap_or_else(|| "-".into()),
crate::output::truncate(r.output.lines().next().unwrap_or(""), 40),
]);
}
print!("{}", table.render());
let s = &report.summary;
let mut line = format!(
"{} run(s) — {} ok, {} failed, {} timeout",
s.runs, s.ok, s.failed, s.timeout
);
if let Some(f) = &s.fastest {
line.push_str(&format!(" — fastest: {f}"));
}
if let Some(c) = &s.cheapest {
line.push_str(&format!(" — cheapest: {c}"));
}
app.log.info(&line);
}
+22 -8
View File
@@ -75,14 +75,14 @@ pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
}
#[derive(Serialize)]
struct LogRow {
ts: String,
kind: String,
agent: Option<String>,
pid: Option<u32>,
exit_code: Option<i32>,
duration_s: Option<u64>,
session: Option<String>,
pub struct LogRow {
pub ts: String,
pub kind: String,
pub agent: Option<String>,
pub pid: Option<u32>,
pub exit_code: Option<i32>,
pub duration_s: Option<u64>,
pub session: Option<String>,
}
impl From<&Event> for LogRow {
@@ -99,6 +99,20 @@ impl From<&Event> for LogRow {
}
}
/// Rows for the web API (issue #54): same shape as `am log --json`.
pub fn rows(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<Vec<LogRow>> {
Ok(filter_events(app, agent, kinds, since, limit)?
.iter()
.map(LogRow::from)
.collect())
}
/// Chars of text after byte offset seen (UTF-8 safe).
pub fn text_after(text: &str, seen: usize) -> String {
text.char_indices()
+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}");
}
}
+489
View File
@@ -0,0 +1,489 @@
//! migrate: transfer the installation and state from machine A to machine B
//! (issue #68). `am migrate export` bundles config, state, journal, history,
//! custom catalogs and backups into a single .amx file with a manifest of
//! hashes; `am migrate import` restores it and runs `am doctor` afterwards.
//! Absolute paths are neutralized ({{STATE_DIR}} / {{HOME}} placeholders) so
//! the bundle is portable. No runtime dependency: the bundle format is a
//! JSON manifest line followed by concatenated blobs.
use super::*;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
pub const BUNDLE_FORMAT: &str = "am-migrate-bundle";
pub const BUNDLE_VERSION: u32 = 1;
/// One file inside the bundle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleFile {
/// Relative path inside the state layout ("state.json", "events/...", ...).
pub path: String,
pub size: u64,
pub sha256: String,
/// Byte offset of the blob inside the bundle.
pub offset: u64,
}
/// Bundle header (first line of the file, then the blobs).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleManifest {
pub format: String,
pub version: u32,
pub created: String,
pub am_version: String,
pub files: Vec<BundleFile>,
}
/// sha256 hex of a byte slice.
pub fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data);
let out = h.finalize();
out.iter().map(|b| format!("{b:02x}")).collect()
}
/// Neutralize absolute paths of machine A into portable placeholders.
/// The source paths are replaced only when they match the current machine.
pub fn neutralize(text: &str, state_dir: &Path, home: &Path) -> String {
let mut out = text.to_string();
let sd = state_dir.to_string_lossy().to_string();
let h = home.to_string_lossy().to_string();
out = out.replace(&sd, "{{STATE_DIR}}");
out = out.replace(&h, "{{HOME}}");
out
}
/// Restore the placeholders with machine B paths.
pub fn deneutralize(text: &str, state_dir: &Path, home: &Path) -> String {
let mut out = text.to_string();
out = out.replace("{{STATE_DIR}}", &state_dir.to_string_lossy());
out = out.replace("{{HOME}}", &home.to_string_lossy());
out
}
/// Collect the files to bundle: config, state, journal, history, catalog
/// cache, backups. Returns (relative path, absolute path, needs path
/// neutralization).
fn collect_files(app: &App) -> Vec<(String, PathBuf, bool)> {
let state = app.events_dir();
let mut out: Vec<(String, PathBuf, bool)> = Vec::new();
// Active config file (when it exists on disk).
if let Some(dir) = &app.paths.config_dir {
let cfg = dir.join(crate::config::CONFIG_FILE_NAME);
if cfg.exists() {
out.push(("config.yaml".to_string(), cfg, true));
}
}
// State file.
let sf = app.paths.state_file.clone();
if sf.exists() {
out.push(("state.json".to_string(), sf, true));
}
// Journal events.
let events = state.join("events");
if let Ok(entries) = std::fs::read_dir(&events) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
let rel = format!(
"events/{}",
p.file_name().unwrap_or_default().to_string_lossy()
);
out.push((rel, p, true));
}
}
} else if let Ok(entries) = std::fs::read_dir(&state) {
// Older layout: journal files sit directly in the state directory.
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
if name.starts_with("events-") && name.ends_with(".jsonl") {
out.push((name, p, true));
}
}
}
// History files (per-session JSONL).
let history = state.join("history");
if let Ok(entries) = std::fs::read_dir(&history) {
for e in entries.flatten() {
let p = e.path();
let rel = format!(
"history/{}",
p.file_name().unwrap_or_default().to_string_lossy()
);
out.push((rel, p, true));
}
}
// Catalog cache (custom catalogs).
let cache = state.join("catalog-cache.json");
if cache.exists() {
out.push(("catalog-cache.json".to_string(), cache, true));
}
// Backups (recursive: one entry per file under backups/<id>/).
let backups = state.join("backups");
if let Ok(entries) = std::fs::read_dir(&backups) {
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let id = p.file_name().unwrap_or_default().to_string_lossy().to_string();
if let Ok(files) = std::fs::read_dir(&p) {
for f in files.flatten() {
let fp = f.path();
if fp.is_file() {
let rel = format!(
"backups/{}/{}",
id,
fp.file_name().unwrap_or_default().to_string_lossy()
);
out.push((rel, fp, false));
}
}
}
}
}
out
}
/// Write the bundle. Returns the number of bundled files.
pub fn export_bundle(app: &App, output: &Path) -> Result<usize> {
let files = collect_files(app);
let mut manifest = BundleManifest {
format: BUNDLE_FORMAT.to_string(),
version: BUNDLE_VERSION,
created: crate::installers::now_rfc3339(),
am_version: env!("CARGO_PKG_VERSION").to_string(),
files: Vec::new(),
};
let state_dir = app.events_dir();
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
let mut blobs: Vec<Vec<u8>> = Vec::new();
let mut offset: u64 = 0;
for (rel, abs, neutralize_paths) in &files {
let raw = std::fs::read(abs)
.with_context(|| format!("cannot read {}", abs.display()))?;
let data = if *neutralize_paths {
// Text files: neutralize absolute paths (works on both YAML and
// JSON since the placeholders are plain strings).
let text = String::from_utf8_lossy(&raw);
let neutral = neutralize(&text, &state_dir, &home);
neutral.into_bytes()
} else {
raw
};
manifest.files.push(BundleFile {
path: rel.clone(),
size: data.len() as u64,
sha256: sha256_hex(&data),
offset,
});
offset += data.len() as u64;
blobs.push(data);
}
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent)?;
}
let mut f = std::fs::File::create(output)
.with_context(|| format!("cannot create {}", output.display()))?;
let header = serde_json::to_string(&manifest)?;
writeln!(f, "{header}")?;
for b in &blobs {
f.write_all(b)?;
}
f.flush()?;
Ok(manifest.files.len())
}
/// Read and verify the bundle: parses the header, checks that every blob is
/// present and its hash matches. Returns (manifest, blob bytes).
pub fn read_bundle(path: &Path) -> Result<(BundleManifest, Vec<Vec<u8>>)> {
let mut raw = Vec::new();
std::fs::File::open(path)
.with_context(|| format!("cannot open {}", path.display()))?
.read_to_end(&mut raw)?;
let nl = raw
.iter()
.position(|&b| b == b'\n')
.ok_or_else(|| anyhow!("{}: missing header line", path.display()))?;
let header: BundleManifest = serde_json::from_slice(&raw[..nl])
.with_context(|| format!("{}: invalid bundle header", path.display()))?;
if header.format != BUNDLE_FORMAT {
anyhow::bail!("{}: not an am-migrate bundle", path.display());
}
let body = &raw[nl + 1..];
let mut blobs = Vec::new();
for f in &header.files {
let start = f.offset as usize;
let end = start + f.size as usize;
if end > body.len() {
anyhow::bail!(
"{}: truncated bundle — '{}' is incomplete (interrupted transfer?)",
path.display(),
f.path
);
}
let data = body[start..end].to_vec();
if sha256_hex(&data) != f.sha256 {
anyhow::bail!(
"{}: checksum mismatch for '{}' — bundle corrupted",
path.display(),
f.path
);
}
blobs.push(data);
}
Ok((header, blobs))
}
/// Restore a bundle into the state layout of machine B. The previous state
/// directory is moved aside (resumable, nothing lost). Returns the list of
/// restored files.
pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<String>> {
let (manifest, blobs) = read_bundle(bundle)?;
let state_dir = app.events_dir();
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
// Resumable import: park the current state aside, then write the new one.
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
if state_dir.exists() {
let park = state_dir.with_extension(format!("prev-{ts}"));
std::fs::rename(&state_dir, &park)
.with_context(|| format!("cannot park old state at {}", park.display()))?;
app.log
.info(&format!("previous state moved aside: {}", park.display()));
}
std::fs::create_dir_all(&state_dir)?;
let mut restored = Vec::new();
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
let rel = Path::new(&bf.path);
let target = if bf.path == "config.yaml" {
// Config goes to the user config directory of machine B.
let dir = crate::config::user_config_dir()
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
std::fs::create_dir_all(&dir)?;
dir.join(crate::config::CONFIG_FILE_NAME)
} else {
state_dir.join(rel)
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = data.clone();
if !rel.starts_with("backups") {
// Text content: restore machine B paths.
let text = String::from_utf8_lossy(&content);
content = deneutralize(&text, &state_dir, &home).into_bytes();
}
std::fs::write(&target, &content)
.with_context(|| format!("cannot write {}", target.display()))?;
restored.push(bf.path.clone());
}
// Config merge: the imported config replaces the local one only when
// confirmed; otherwise it is written next to it as config.migrated.yaml.
let cfg_rel = "config.yaml";
if restored.iter().any(|r| r == cfg_rel) && !confirm {
let dir = crate::config::user_config_dir()
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
if imported.exists() {
let target = dir.join("config.migrated.yaml");
std::fs::rename(&imported, &target)?;
app.log.info(&format!(
"imported config written as {} (use --yes to replace the local config)",
target.display()
));
}
}
restored.sort();
Ok(restored)
}
/// am migrate export/import.
pub fn run(app: &App, export: bool, output: Option<&Path>, bundle: Option<&Path>) -> Result<i32> {
if export {
let default_out = PathBuf::from("agent-manager-migrate.amx");
let out = output.unwrap_or(&default_out);
let n = export_bundle(app, out)?;
app.log.success(&format!(
"bundle written to {} ({n} files) — copy it to machine B and run 'am migrate import {}'",
out.display(),
out.display()
));
if let Some(dir) = app.paths.config_dir.as_ref() {
if dir.join(crate::config::CONFIG_FILE_NAME).exists() {
app.log.info("the bundle contains config.yaml, state.json, events, history, catalog cache and backups — absolute paths are neutralized");
}
}
return Ok(0);
}
let bundle = bundle.ok_or_else(|| anyhow!("usage: am migrate import <bundle.amx>"))?;
if !bundle.exists() {
anyhow::bail!("bundle not found: {}", bundle.display());
}
let ask = format!(
"import {} into this machine? The current state will be moved aside",
bundle.display()
);
let confirmed = app.confirm(&ask)?;
if !confirmed {
app.log.info("import cancelled");
return Ok(0);
}
let restored = import_bundle(app, bundle, app.cli.yes)?;
app.log.success(&format!("imported {} file(s)", restored.len()));
// Post-import verification (issue #68: doctor passes after import).
let problems = crate::commands::doctor_cmd::run(app, false, None)?;
if problems > 0 {
app.log.warn("doctor found issues after the import — review the report above");
} else {
app.log.success("doctor: environment OK after import");
}
app.log.info(
"next steps: (1) verify 'am list' and 'am status', (2) reinstall agents if needed ('am install <agent>'), (3) start a session",
);
Ok(0)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_is_stable() {
let h1 = sha256_hex(b"hello");
let h2 = sha256_hex(b"hello");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
assert_ne!(h1, sha256_hex(b"world"));
}
#[test]
fn neutralization_round_trip() {
let state = PathBuf::from("/mnt/machine-a/state");
let home = PathBuf::from("/home/user-a");
let text = format!(
"install_dir: {}\nrun: {}/bin/tool\n",
state.display(),
home.display()
);
let neutral = neutralize(&text, &state, &home);
assert!(neutral.contains("{{STATE_DIR}}"));
assert!(neutral.contains("{{HOME}}"));
assert!(!neutral.contains("machine-a"));
let state_b = PathBuf::from("/data/machine-b/state");
let home_b = PathBuf::from("/home/user-b");
let back = deneutralize(&neutral, &state_b, &home_b);
assert!(back.contains("/data/machine-b/state"));
assert!(back.contains("/home/user-b"));
}
fn test_app(tag: &str) -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = crate::app::App::from_cli(cli).expect("app should build");
app.paths.state_file = dir.path().join(format!("state-{tag}/state.json"));
app.paths.config_dir = Some(dir.path().to_path_buf());
app
}
#[test]
fn export_import_round_trip() {
let app = test_app("exp");
let state = app.events_dir();
std::fs::create_dir_all(&state).unwrap();
std::fs::write(app.paths.state_file.clone(), "{\"version\":2,\"installed\":{}}").unwrap();
std::fs::write(state.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap();
std::fs::write(state.join("catalog-cache.json"), "{}").unwrap();
// The config file referenced by config_dir must exist to be bundled.
let cfg_path = app
.paths
.config_dir
.as_ref()
.unwrap()
.join(crate::config::CONFIG_FILE_NAME);
std::fs::write(&cfg_path, "version: \"1.0\"\nagents: []\n").unwrap();
let bundle = std::env::temp_dir().join(format!("am-mig-{}.amx", std::process::id()));
let n = export_bundle(&app, &bundle).unwrap();
assert!(n >= 3);
// Import into a second app with a different state dir.
let app_b = test_app("imp");
let restored = import_bundle(&app_b, &bundle, true).unwrap();
assert!(restored.iter().any(|r| r == "state.json"));
assert!(restored.iter().any(|r| r == "events-202608.jsonl"));
// State restored with the same content.
let state_b = app_b.events_dir();
let sf = state_b.join("state.json");
assert!(sf.exists());
let text = std::fs::read_to_string(sf).unwrap();
assert!(text.contains("\"version\":2"));
assert!(state_b.join("events-202608.jsonl").exists());
let _ = std::fs::remove_file(&bundle);
}
#[test]
fn truncated_bundle_is_detected() {
let app = test_app("trunc");
let state = app.events_dir();
std::fs::create_dir_all(&state).unwrap();
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
let bundle = std::env::temp_dir().join(format!("am-trunc-{}.amx", std::process::id()));
export_bundle(&app, &bundle).unwrap();
// Truncate the last byte.
let mut data = std::fs::read(&bundle).unwrap();
data.pop();
std::fs::write(&bundle, data).unwrap();
let err = read_bundle(&bundle).unwrap_err().to_string();
assert!(err.contains("truncated") || err.contains("checksum"), "{err}");
let _ = std::fs::remove_file(&bundle);
}
#[test]
fn tampered_blob_is_detected() {
let app = test_app("tamper");
let state = app.events_dir();
std::fs::create_dir_all(&state).unwrap();
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
let bundle = std::env::temp_dir().join(format!("am-tamp-{}.amx", std::process::id()));
export_bundle(&app, &bundle).unwrap();
// Flip a byte inside the first blob (after the header line), not in
// the header itself.
let mut data = std::fs::read(&bundle).unwrap();
let nl = data.iter().position(|&b| b == b'\n').unwrap();
let mid = nl + 1 + (data.len() - nl - 1) / 2;
data[mid] ^= 0xFF;
std::fs::write(&bundle, data).unwrap();
let err = read_bundle(&bundle).unwrap_err().to_string();
assert!(err.contains("checksum"), "{err}");
let _ = std::fs::remove_file(&bundle);
}
}
+92 -8
View File
@@ -3,6 +3,8 @@
pub mod agents_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
pub mod catalog_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod dashboard_cmd;
@@ -13,30 +15,43 @@ pub mod history_cmd;
pub mod info_cmd;
pub mod init_cmd;
pub mod install_cmd;
pub mod lab_cmd;
pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod man_cmd;
pub mod models_cmd;
pub mod monitor_cmd;
pub mod migrate_cmd;
pub mod open_cmd;
pub mod profile_cmd;
pub mod projects_cmd;
pub mod run_cmd;
pub mod schedule_cmd;
pub mod search_cmd;
pub mod service_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod self_update;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod suggest_cmd;
pub mod sync_cmd;
pub mod playbook_cmd;
pub mod plugins_cmd;
pub mod theme_cmd;
pub mod timeline_cmd;
pub mod tip_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod watch_cmd;
pub mod version_cmd;
pub mod watch_cmd;
pub mod web_cmd;
use crate::app::App;
use crate::cli::Command;
use crate::config::{AgentDef, MethodSpec};
use crate::config::{AgentDef, MethodSpec, UpdatePolicy};
use crate::state::InstalledEntry;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
@@ -82,6 +97,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Agents { installed } => agents_cmd::run(app, *installed),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Models(args) => models_cmd::run(app, args),
Command::Catalog(sub) => catalog_cmd::run(app, sub),
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -94,7 +112,17 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
timeout,
} => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
Command::Stats { agent, period, costs } => {
stats_cmd::run(app, agent.as_deref(), period.as_deref(), *costs)
}
Command::Monitor { interval, json } => {
monitor_cmd::run(app, *interval, *json)
}
Command::Web(args) => web_cmd::run(app, args.port, args.no_open),
Command::Sync { message } => sync_cmd::run(app, message.as_deref()),
Command::Migrate { export, output, bundle } => {
migrate_cmd::run(app, *export, output.as_deref(), bundle.as_deref())
}
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report {
last_week,
@@ -121,6 +149,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status,
show,
resume,
export,
output,
retention,
} => sessions_cmd::run(
app,
agent.as_deref(),
@@ -128,6 +159,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status.as_deref(),
show.as_deref(),
resume.as_deref(),
export.as_deref(),
output.as_deref(),
*retention,
),
Command::Install {
agent,
@@ -135,7 +169,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
force,
} => install_cmd::run(app, agent, method.as_deref(), *force),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Update { agent, all, rollback } => {
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
}
Command::Audit => audit_cmd::run(app),
Command::Search { keyword, category, tag } => {
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
}
@@ -147,6 +184,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
session,
limit,
rerun,
range,
save,
} => history_cmd::run(
app,
kind.as_deref(),
@@ -156,14 +195,59 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
session.as_deref(),
*limit,
*rerun,
range.as_deref(),
save.as_deref(),
),
Command::Playbook { path, var } => playbook_cmd::run(app, path, var),
Command::Lab(args) => lab_cmd::run(app, args),
Command::Plugins(args) => plugins_cmd::run(app, args),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force } => init_cmd::run(app, *force),
Command::Init { force, template } => {
init_cmd::run(app, *force, template.as_deref())
}
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),
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix, watch } => {
doctor_cmd::run(app, *fix, *watch)
}
Command::Run { agent, model, container, args } => {
run_cmd::run(app, agent, model.as_deref(), *container, args)
}
Command::Service(svc) => match svc {
crate::cli::ServiceCmd::Install { agent, autostart } => {
service_cmd::run(app, "install", Some(agent), *autostart)
}
crate::cli::ServiceCmd::Uninstall { agent } => {
service_cmd::run(app, "uninstall", Some(agent), false)
}
crate::cli::ServiceCmd::Status { agent } => {
service_cmd::run(app, "status", Some(agent), false)
}
},
Command::Schedule(sched) => match sched {
crate::cli::ScheduleCmd::Add { command, at } => {
// '--at HH:MM' may land inside the trailing command args
// (allow_hyphen_values): strip it and recover the value.
let mut cmd = command.clone();
let mut at_value = at.clone();
if let Some(idx) = cmd.iter().position(|a| a == "--at") {
if idx + 1 < cmd.len() {
at_value = at_value.or_else(|| Some(cmd[idx + 1].clone()));
cmd.remove(idx + 1);
}
cmd.remove(idx);
}
schedule_cmd::run(app, "add", cmd, at_value.as_deref())
}
crate::cli::ScheduleCmd::List => schedule_cmd::run(app, "list", vec![], None),
crate::cli::ScheduleCmd::Remove { id } => {
schedule_cmd::run(app, "remove", vec![id.clone()], None)
}
crate::cli::ScheduleCmd::Run { id } => schedule_cmd::run(app, "run", vec![id.clone()], None),
},
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
+198
View File
@@ -0,0 +1,198 @@
//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio)
//! and pruning of unused models (issues #70 #73, axe 11).
use super::*;
use crate::cli::ModelsArgs;
use crate::events::{Event, EventKind};
use crate::models::{self, RuntimeModel};
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize)]
struct ModelRow {
runtime: String,
name: String,
size_bytes: Option<u64>,
quantization: Option<String>,
last_used: Option<String>,
}
#[derive(Serialize)]
struct RuntimeRow {
runtime: String,
detected: bool,
note: Option<String>,
models: usize,
}
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
if args.prune {
return prune(app, args);
}
let inventory = models::detect(app);
if app.json() {
print_json(&serde_json::json!({
"runtimes": inventory.runtimes,
"models": inventory.models,
}));
return Ok(0);
}
let theme = app.theme();
let color = app.color();
// Runtime status lines.
for r in &inventory.runtimes {
let icon = if r.detected { "✓" } else { "✗" };
let note = r.note.as_deref().unwrap_or("");
println!(" {icon} {} — {note}", r.runtime);
}
if !inventory.any_runtime() {
app.log.info(
"no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API",
);
return Ok(0);
}
if inventory.models.is_empty() {
app.log.info("runtimes detected but no models found");
return Ok(0);
}
// Table: runtime | model | size | quant | last used.
let rows: Vec<Vec<String>> = inventory
.models
.iter()
.map(|m| {
vec![
m.runtime.clone(),
m.name.clone(),
human_size(m.size_bytes),
m.quantization.clone().unwrap_or_else(|| "—".to_string()),
m.last_used.clone().unwrap_or_else(|| "—".to_string()),
]
})
.collect();
let _ = (theme, color);
print!(
"{}",
crate::output::boxed_table(
vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"],
rows,
app.theme(),
app.color(),
)
);
println!(
"\n {} model(s) — 'am models --prune --dry-run' lists unused ones",
inventory.models.len()
);
Ok(0)
}
/// prune: list unused models, then delete them after confirmation.
fn prune(app: &App, args: &ModelsArgs) -> Result<i32> {
let days = args.days.unwrap_or_else(|| {
app.config
.settings
.models_prune_days
.unwrap_or(models::DEFAULT_PRUNE_DAYS)
});
let inventory = models::detect(app);
let candidates = models::prune_candidates(&inventory.models, days);
let _ = &inventory;
if app.json() {
let rows: Vec<ModelRow> = candidates
.iter()
.map(|m| ModelRow {
runtime: m.runtime.clone(),
name: m.name.clone(),
size_bytes: m.size_bytes,
quantization: m.quantization.clone(),
last_used: m.last_used.clone(),
})
.collect();
print_json(&rows);
return Ok(if rows.is_empty() { 0 } else { 1 });
}
if candidates.is_empty() {
app.log.success(&format!(
"no unused model found (threshold: {days} days; ':latest' models are always kept)"
));
return Ok(0);
}
println!(
"{} model(s) unused for more than {days} days (':latest' models are protected):",
candidates.len()
);
for m in &candidates {
let size = human_size(m.size_bytes);
let last = m.last_used.as_deref().unwrap_or("unknown");
println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name);
}
if args.dry_run {
app.log.info("dry-run: nothing was deleted — remove --dry-run to prune");
return Ok(0);
}
let asked = app
.confirm(&format!("delete these {} model(s)?", candidates.len()))?;
if !asked {
app.log.info("prune cancelled");
return Ok(0);
}
let mut removed = 0;
let mut errors = 0;
for m in &candidates {
match models::remove_model(m) {
Ok(()) => {
app.emit(
&Event::now(EventKind::Model)
.with_agent(m.runtime.clone())
.with_args(vec![m.name.clone()])
.with_reason("prune"),
);
app.log.success(&format!("removed {} ({})", m.name, m.runtime));
removed += 1;
}
Err(e) => {
app.log.error(&format!("{}: {e:#}", m.name));
errors += 1;
}
}
}
// The inventory cache is stale after a prune: drop it so the next run
// rescans the runtimes.
let cache_path = app.events_dir().join("models-cache.json");
let _ = std::fs::remove_file(&cache_path);
if errors > 0 {
app.log
.error(&format!("{errors} model(s) could not be removed"));
return Ok(1);
}
app.log
.success(&format!("{removed} model(s) removed from the local runtimes"));
Ok(0)
}
/// Human-readable size ("1.2 GB", "450 MB", "—" when unknown).
fn human_size(bytes: Option<u64>) -> String {
match bytes {
None => "—".to_string(),
Some(b) => {
let kb = b as f64 / 1024.0;
let mb = kb / 1024.0;
let gb = mb / 1024.0;
if gb >= 1.0 {
format!("{gb:.1} GB")
} else if mb >= 1.0 {
format!("{mb:.0} MB")
} else {
format!("{kb:.0} KB")
}
}
}
}
/// Usage of each model name across sessions (for --json consumers).
#[allow(dead_code)]
fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap<String, u64> {
BTreeMap::new()
}
+405
View File
@@ -0,0 +1,405 @@
//! monitor: real-time TUI of the managed processes (issue #50). Lists every
//! running agent with CPU %, RSS, uptime; alerts on configurable thresholds
//! (settings.monitor_thresholds) with a desktop notification + journal event.
//! `--json` prints one JSON document per tick for scripts.
use super::*;
use crate::config::MonitorThresholds;
use crate::events::{Event, EventKind};
use anyhow::{Context, Result};
use serde::Serialize;
use std::process::Command;
use std::time::Duration;
/// One measured process row.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AgentMetric {
pub agent: String,
pub pid: u32,
pub cpu_pct: f64,
pub mem_mb: f64,
pub uptime_s: u64,
/// "running" when the process answers, "dead" otherwise.
pub state: String,
}
/// Raw reading of a process (platform-dependent).
#[derive(Debug, Clone, Copy, Default)]
pub struct RawReading {
/// Total CPU time consumed so far (seconds).
pub cpu_total_s: f64,
/// Resident set size in bytes.
pub rss_bytes: u64,
}
/// Read the raw counters of a process. Windows: PowerShell Get-Process
/// (CPU = total seconds, WorkingSet64 = RSS). Unix: ps.
fn raw_reading(pid: u32) -> Option<RawReading> {
#[cfg(windows)]
{
let out = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"Get-Process -Id {pid} | Select-Object -ExpandProperty CPU; Get-Process -Id {pid} | Select-Object -ExpandProperty WorkingSet64"
),
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
let cpu: f64 = lines.next()?.trim().parse().ok()?;
let rss: u64 = lines.next()?.trim().parse().ok()?;
Some(RawReading {
cpu_total_s: cpu,
rss_bytes: rss,
})
}
#[cfg(unix)]
{
let out = Command::new("ps")
.args(["-o", "time=,rss=", "-p", &pid.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut parts = text.split_whitespace();
let time = parts.next()?;
let rss_kb: u64 = parts.next()?.parse().ok()?;
Some(RawReading {
cpu_total_s: parse_ps_time(time),
rss_bytes: rss_kb * 1024,
})
}
}
/// Parse `ps` cumulative time "MM:SS" or "HH:MM:SS" into seconds.
fn parse_ps_time(t: &str) -> f64 {
let parts: Vec<f64> = t
.split(':')
.filter_map(|p| p.parse().ok())
.collect();
match parts.as_slice() {
[m, s] => m * 60.0 + s,
[h, m, s] => h * 3600.0 + m * 60.0 + s,
_ => 0.0,
}
}
/// Turn raw readings (taken `interval_s` apart) into a metric row.
/// Pure: unit tested.
pub fn to_metric(
agent: &str,
pid: u32,
before: Option<RawReading>,
after: RawReading,
started_at: Option<&str>,
interval_s: f64,
) -> AgentMetric {
let cpu_pct = match before {
Some(b) if interval_s > 0.0 => {
let delta = (after.cpu_total_s - b.cpu_total_s).max(0.0);
(delta / interval_s * 100.0).min(100.0 * 64.0) // multi-core cap, generous
}
_ => 0.0,
};
let uptime_s = started_at
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64)
.unwrap_or(0);
AgentMetric {
agent: agent.to_string(),
pid,
cpu_pct,
mem_mb: after.rss_bytes as f64 / (1024.0 * 1024.0),
uptime_s,
state: "running".to_string(),
}
}
/// Decide which alerts fire for a metric against the thresholds. Pure.
pub fn alerts_for(m: &AgentMetric, t: &MonitorThresholds) -> Vec<String> {
let mut out = Vec::new();
if let Some(cpu) = t.cpu_pct {
if cpu > 0.0 && m.cpu_pct > cpu {
out.push(format!(
"{} cpu {:.1}% > {cpu}%",
m.agent, m.cpu_pct
));
}
}
if let Some(mem) = t.mem_mb {
if mem > 0 && m.mem_mb > mem as f64 {
out.push(format!(
"{} rss {:.0} MB > {mem} MB",
m.agent, m.mem_mb
));
}
}
out
}
/// Collect the metrics of every managed agent with a live PID.
pub fn collect(app: &App, interval_s: f64) -> Vec<AgentMetric> {
let mut out = Vec::new();
let Ok(sf) = app.state.load() else {
return out;
};
for (name, entry) in &sf.installed {
let Some(pid) = entry.pid else { continue };
let Some(after) = raw_reading(pid) else {
out.push(AgentMetric {
agent: name.clone(),
pid,
state: "dead".to_string(),
..Default::default()
});
continue;
};
let before = if interval_s > 0.0 {
std::thread::sleep(Duration::from_secs_f64(interval_s));
raw_reading(pid)
} else {
None
};
out.push(to_metric(
name,
pid,
before,
after,
entry.started_at.as_deref(),
interval_s,
));
}
out
}
/// Emit one JSON document (one row per process) — script-friendly.
fn print_json_rows(rows: &[AgentMetric]) {
crate::output::print_json(&rows);
}
/// Run the monitor: TUI by default, --json for scripts.
pub fn run(app: &App, interval: Option<u64>, json: bool) -> Result<i32> {
let secs = interval.unwrap_or(2).max(1) as f64;
let thresholds = app
.config
.settings
.monitor_thresholds
.clone()
.unwrap_or_default();
if json {
// One JSON document per tick, then sleep; Ctrl-C stops.
loop {
let rows = collect(app, 0.0);
print_json_rows(&rows);
for m in &rows {
fire_alerts(app, m, &thresholds);
}
std::thread::sleep(Duration::from_secs_f64(secs));
}
}
tui_loop(app, secs, &thresholds)
}
/// Fire a desktop notification + journal event when a threshold is crossed.
/// Alerts are rate-limited by the caller (one per tick).
fn fire_alerts(app: &App, m: &AgentMetric, t: &MonitorThresholds) {
for alert in alerts_for(m, t) {
let _ = crate::process::desktop_notify("agent-manager monitor", &alert);
app.emit(
&Event::now(EventKind::Alert)
.with_agent(m.agent.clone())
.with_pid(m.pid)
.with_reason(format!("monitor: {alert}")),
);
}
}
/// ratatui loop: list of managed processes, refresh every `secs`, q/Esc quits.
fn tui_loop(app: &App, secs: f64, thresholds: &MonitorThresholds) -> Result<i32> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::DefaultTerminal;
let mut terminal = ratatui::init();
let result = (|| -> Result<i32> {
loop {
let rows = collect(app, secs);
for m in &rows {
fire_alerts(app, m, thresholds);
}
terminal.draw(|f| {
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
let title = Line::from(vec![
Span::styled(
" am monitor ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} managed process(es)", rows.len()),
Style::default().fg(Color::Gray),
),
]);
f.render_widget(
Block::default().borders(Borders::TOP).title(title),
chunks[0],
);
let items: Vec<ListItem> = rows
.iter()
.map(|m| {
let state = if m.state == "dead" {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
};
ListItem::new(Line::from(vec![
Span::styled(
format!(
"{:<20} pid {:>7} cpu {:>6.1}% rss {:>8.0} MB up {}",
m.agent,
m.pid,
m.cpu_pct,
m.mem_mb,
human_uptime(m.uptime_s),
),
state,
),
]))
})
.collect();
f.render_widget(
List::new(items).block(Block::default().borders(Borders::ALL)),
chunks[1],
);
let hint = Line::from(Span::styled(
" q / Esc: quit ",
Style::default().fg(Color::DarkGray),
));
f.render_widget(hint, chunks[2]);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press
&& matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
{
return Ok(0);
}
}
}
}
})();
ratatui::restore();
result
}
fn human_uptime(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_metric_computes_cpu_and_mem() {
let before = RawReading {
cpu_total_s: 10.0,
rss_bytes: 2 * 1024 * 1024,
};
let after = RawReading {
cpu_total_s: 12.0,
rss_bytes: 4 * 1024 * 1024,
};
let m = to_metric("a", 42, Some(before), after, None, 2.0);
assert_eq!(m.pid, 42);
assert!((m.cpu_pct - 100.0).abs() < 1e-6, "{}", m.cpu_pct);
assert!((m.mem_mb - 4.0).abs() < 1e-6);
assert_eq!(m.state, "running");
}
#[test]
fn to_metric_first_sample_zero_cpu() {
let after = RawReading {
cpu_total_s: 5.0,
rss_bytes: 1024 * 1024,
};
let m = to_metric("a", 1, None, after, None, 2.0);
assert_eq!(m.cpu_pct, 0.0);
}
#[test]
fn to_metric_parses_uptime() {
let now = chrono::Utc::now();
let started = now - chrono::Duration::minutes(5);
let after = RawReading {
cpu_total_s: 1.0,
rss_bytes: 1024 * 1024,
};
let m = to_metric("a", 1, None, after, Some(&started.to_rfc3339()), 0.0);
assert!((260..=320).contains(&m.uptime_s), "{}", m.uptime_s);
}
#[test]
fn alerts_fire_on_thresholds() {
let m = AgentMetric {
agent: "a".into(),
pid: 1,
cpu_pct: 95.0,
mem_mb: 2000.0,
uptime_s: 0,
state: "running".into(),
};
let t = MonitorThresholds {
cpu_pct: Some(80.0),
mem_mb: Some(1024),
};
let alerts = alerts_for(&m, &t);
assert_eq!(alerts.len(), 2);
// Below thresholds → nothing.
let quiet = AgentMetric {
cpu_pct: 10.0,
mem_mb: 100.0,
..m.clone()
};
assert!(alerts_for(&quiet, &t).is_empty());
// 0 disables the threshold.
let off = MonitorThresholds {
cpu_pct: Some(0.0),
mem_mb: Some(0),
};
assert!(alerts_for(&m, &off).is_empty());
}
#[test]
fn parse_ps_time_formats() {
assert!((parse_ps_time("01:30") - 90.0).abs() < 1e-9);
assert!((parse_ps_time("1:02:03") - 3723.0).abs() < 1e-9);
}
}
+20
View File
@@ -0,0 +1,20 @@
//! playbook: replay a saved playbook step by step (issue #51).
//! Playbooks are created with 'am history <from>..<to> --save <file>'.
use super::*;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
pub fn run(app: &App, path: &Path, vars: &[String]) -> Result<i32> {
let mut values = BTreeMap::new();
for v in vars {
let Some((k, val)) = v.split_once('=') else {
anyhow::bail!("--var expects name=value, got '{v}'");
};
values.insert(k.trim().to_string(), val.trim().to_string());
}
let resolved = crate::playbook::resolve_dest(app, path);
let used = if resolved.exists() { resolved } else { path.to_path_buf() };
crate::playbook::run_playbook(app, &used, &values)
}
+102
View File
@@ -0,0 +1,102 @@
//! plugins: list the event plugins and test one against a synthetic event
//! (issue #75). 'am plugins --test <name>' is the CI hook: it exercises the
//! JSON contract end to end and exits non-zero on failure.
use super::*;
use crate::cli::PluginsArgs;
use crate::plugins::{self, PluginStatus};
use anyhow::Result;
pub fn run(app: &App, args: &PluginsArgs) -> Result<i32> {
let dir = plugins::dir(app);
let all = plugins::discover(app, &dir);
if let Some(name) = &args.test {
let Some(plugin) = all.iter().find(|p| &p.name == name) else {
anyhow::bail!(
"unknown plugin '{name}' — 'am plugins' lists the plugins of {}",
dir.display()
);
};
// Synthetic event (a start, agent 'test') to exercise the contract.
let event = crate::events::Event::now(crate::events::EventKind::Start)
.with_agent("test")
.with_args(vec!["--probe".to_string()]);
let r = plugins::run_plugin(app, plugin, "on_start", &event);
if app.json() {
crate::output::print_json(&serde_json::json!({
"name": plugin.name,
"status": r.status.as_str(),
"exit_code": r.exit_code,
"duration_ms": r.duration_ms,
"response": r.response,
"stdout": r.stdout,
"stderr": r.stderr,
"error": r.error,
}));
} else {
app.log.info(&format!(
"plugin '{}' — {} in {}ms",
plugin.name,
r.status.as_str(),
r.duration_ms
));
if let Some(code) = r.exit_code {
app.log.info(&format!("exit code: {code}"));
}
if let Some(resp) = &r.response {
if let Some(msg) = &resp.message {
app.log.info(&format!("message: {msg}"));
}
if let Some(err) = &resp.error {
app.log.warn(&format!("error: {err}"));
}
}
if !r.stdout.trim().is_empty() {
app.log.info(&format!("stdout: {}", r.stdout.trim()));
}
if !r.stderr.trim().is_empty() {
app.log.warn(&format!("stderr: {}", r.stderr.trim()));
}
if let Some(e) = &r.error {
app.log.warn(&format!("{e}"));
}
}
return Ok(if r.status == PluginStatus::Ok { 0 } else { 1 });
}
if app.json() {
let items: Vec<serde_json::Value> = all
.iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"events": p.events,
"timeout_secs": p.timeout.as_secs(),
"script": p.file.display().to_string(),
})
})
.collect();
crate::output::print_json(&items);
return Ok(0);
}
if all.is_empty() {
app.log.info(&format!(
"no plugins in {} — copy one from examples/plugins/ (notify, ci-webhook)",
dir.display()
));
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["PLUGIN", "EVENTS", "TIMEOUT", "SCRIPT"]);
for p in &all {
table.row(vec![
p.name.clone(),
p.events.join(", "),
format!("{}s", p.timeout.as_secs()),
p.file.display().to_string(),
]);
}
print!("{}", table.render());
Ok(0)
}
+138 -3
View File
@@ -69,13 +69,30 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
&extra_args,
&extra_env,
)?;
// --model (issue #71): resolve against the local runtimes, then
// transmit through the agent's declared channel (env var or flag).
let mut extra_args = extra_args;
let mut extra_env = extra_env;
if let Some(m) = opts.model.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
// Issue #57: --parallel launches every member back-to-back without
// waiting (the OS spawns them concurrently); the default waits each
// health check before the next member.
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
if !opts.parallel {
// Issue #57: block until healthy before the next member.
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
}
}
return Ok(0);
}
@@ -299,10 +316,29 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
pub fn run(
app: &App,
target: &str,
model: Option<&str>,
container: bool,
extra: &[OsString],
) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
// Issue #58: --container runs the agent inside its container profile.
if container {
return run_container(app, agent, extra);
}
let mut extra_args: Vec<String> = extra
.iter()
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = model {
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
@@ -334,11 +370,110 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
Ok(code)
}
/// Issue #58: run the agent inside its container profile. Detects the
/// runtime (podman > docker), mounts state/log dirs and the profile
/// volumes/network/env, then execs the container in the foreground.
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
let Some(spec) = &agent.container else {
bail!(
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
agent.name
);
};
let Some(runtime) = crate::automation::detect_runtime() else {
bail!(
"no container runtime found (docker or podman) — install one or drop --container"
);
};
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
let log_dir = &app.paths.log_dir;
let mut cmd = crate::automation::container_command(
&runtime,
&agent.name,
spec,
state_dir,
log_dir,
)?;
// The agent's run command + extra args become the container command.
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
cmd.push(exec.program);
cmd.extend(exec.args.iter().cloned());
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
if app.dry_run() {
app.log.dry(&format!("would run: {}", cmd.join(" ")));
return Ok(0);
}
app.log.info(&format!(
"running '{}' in a container ({runtime})",
agent.name
));
let status = Command::new(&cmd[0])
.args(&cmd[1..])
.status()
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_reason(format!("container ({runtime})"))
.with_args(cmd.clone())
.with_exit_code(code),
);
let _ = app.state.touch(&agent.name);
Ok(code)
}
/// Current directory as a string (None when unavailable).
fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string())
}
/// Resolve --model against the local runtimes and build the transmission
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
/// model is an explicit error listing the available runtimes.
pub fn apply_model(
app: &App,
agent: &AgentDef,
model: &str,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let inventory = crate::models::detect(app);
if inventory.find(model).is_none() {
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
let hint = if known.is_empty() {
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
} else {
format!("available models: {} — run 'am models' for details", known.join(", "))
};
bail!("model '{model}' not found on any local runtime ({hint})");
}
let mut args = Vec::new();
let mut env = BTreeMap::new();
if let Some(var) = &agent.model_env {
env.insert(var.clone(), model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {var}",
agent.name
));
} else if let Some(flag) = &agent.model_arg {
args.push(flag.clone());
args.push(model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {flag}",
agent.name
));
} else {
// Generic fallback: many local agents read AM_MODEL; the config can
// pin a specific channel (model_env / model_arg) instead.
env.insert("AM_MODEL".to_string(), model.to_string());
app.log.info(&format!(
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
agent.name
));
}
Ok((args, env))
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
+217
View File
@@ -0,0 +1,217 @@
//! schedule: plan am commands (axe 6, issue #56). Schedules live in
//! state.json; a daily OS-level task (crontab / Task Scheduler) points back
//! at 'am schedule-run <id>' which executes the stored command, records the
//! result and notifies on failure.
use super::*;
use crate::automation;
use crate::events::{Event, EventKind};
use crate::state::ScheduleEntry;
use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, action: &str, command: Vec<String>, at: Option<&str>) -> Result<i32> {
match action {
"add" => add(app, command, at),
"list" => list(app),
"remove" => {
let id = command
.first()
.ok_or_else(|| anyhow!("schedule remove requires an id"))?;
remove(app, id)
}
"run" => {
let id = command
.first()
.ok_or_else(|| anyhow!("schedule-run requires an id"))?;
run_one(app, id)
}
_ => bail!("usage: am schedule add <cmd...> | am schedule list | am schedule remove <id> | am schedule run <id>"),
}
}
fn task_name(id: &str) -> String {
format!("am-schedule-{id}")
}
/// Register the OS-level daily task (crontab on unix, schtasks on Windows).
/// Best effort: the schedule stays in state.json even if the OS layer
/// cannot be reached (warned, not fatal).
fn register_os_task(app: &App, entry: &ScheduleEntry) {
let am_bin = automation::am_bin_quoted();
let id = &entry.id;
if cfg!(windows) {
let status = automation::schtasks_daily(&entry.at, &am_bin, id, &task_name(id))
.status()
.unwrap_or_default();
if !status.success() {
app.log
.warn("schtasks failed — the schedule is stored but not registered at OS level");
}
} else {
// Append to the user crontab (dedup first).
let existing = Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if existing.contains(&format!("schedule-run {id}")) {
return;
}
let line = automation::crontab_line(&entry.at, &am_bin, id);
let mut next = existing.trim_end().to_string();
next.push('\n');
next.push_str(&line);
next.push('\n');
let mut child = Command::new("crontab")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("crontab spawn");
use std::io::Write;
let _ = child
.stdin
.as_mut()
.map(|s| s.write_all(next.as_bytes()));
let _ = child.wait();
}
}
fn add(app: &App, command: Vec<String>, at: Option<&str>) -> Result<i32> {
if command.is_empty() {
bail!("schedule add requires a command — ex: am schedule add update --all");
}
if app.dry_run() {
app.log.dry(&format!("would schedule: {}", command.join(" ")));
return Ok(0);
}
let id = crate::repl::session_id();
let entry = ScheduleEntry {
id,
cadence: "daily".to_string(),
command,
at: at.unwrap_or("08:00").to_string(),
created_at: crate::installers::now_rfc3339(),
last_run: None,
last_exit: None,
};
let mut sf = app.state.load()?;
sf.schedules.push(entry.clone());
app.state.save(&sf)?;
register_os_task(app, &entry);
app.emit(
&Event::now(EventKind::Config)
.with_agent("schedule".to_string())
.with_reason(format!("schedule add {} ({})", entry.id, entry.at)),
);
app.log.success(&format!(
"scheduled daily at {}: {} (id {})",
entry.at,
entry.command.join(" "),
entry.id
));
Ok(0)
}
fn list(app: &App) -> Result<i32> {
let sf = app.state.load()?;
if sf.schedules.is_empty() {
app.log.info("no schedule — 'am schedule add <cmd>' to create one (ex: am schedule add update --all)");
return Ok(0);
}
println!("schedules:");
for e in &sf.schedules {
let last = e
.last_run
.as_deref()
.map(|t| {
e.last_exit
.map(|c| format!("{t} (exit {c})"))
.unwrap_or_else(|| t.to_string())
})
.unwrap_or_else(|| "never".to_string());
println!(
" {} {} {} cmd: {} last: {}",
e.id,
e.cadence,
e.at,
e.command.join(" "),
last
);
}
Ok(0)
}
fn remove(app: &App, id: &str) -> Result<i32> {
let mut sf = app.state.load()?;
let before = sf.schedules.len();
sf.schedules.retain(|e| e.id != id);
if sf.schedules.len() == before {
bail!("unknown schedule '{id}' — 'am schedule list' shows them");
}
app.state.save(&sf)?;
// Best-effort OS cleanup.
if cfg!(windows) {
let _ = Command::new("schtasks")
.args(["/delete", "/tn", &task_name(id), "/f"])
.status();
} else {
let existing = Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let cleaned: Vec<&str> = existing
.lines()
.filter(|l| !l.contains(&format!("schedule-run {id}")))
.collect();
let text = format!("{}\n", cleaned.join("\n"));
let mut child = Command::new("crontab")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("crontab spawn");
use std::io::Write;
let _ = child
.stdin
.as_mut()
.map(|s| s.write_all(text.as_bytes()));
let _ = child.wait();
}
app.log.success(&format!("schedule '{id}' removed"));
Ok(0)
}
/// Execute one schedule (invoked by the OS task). Records last_run /
/// last_exit, emits a Schedule event and notifies on failure.
fn run_one(app: &App, id: &str) -> Result<i32> {
let sf = app.state.load()?;
let entry = sf
.schedules
.iter()
.find(|e| e.id == id)
.cloned()
.ok_or_else(|| anyhow!("unknown schedule '{id}'"))?;
app.log.info(&format!("running schedule {id}: {}", entry.command.join(" ")));
let code = automation::schedule_run_command(app, &entry)?;
// Record the outcome.
let mut sf = app.state.load()?;
if let Some(e) = sf.schedules.iter_mut().find(|e| e.id == id) {
e.last_run = Some(crate::installers::now_rfc3339());
e.last_exit = Some(code);
}
app.state.save(&sf)?;
app.emit(
&Event::now(EventKind::Run)
.with_agent("schedule".to_string())
.with_exit_code(code)
.with_reason(format!("schedule {id}: {}", entry.command.join(" "))),
);
if code == 0 {
app.log.success(&format!("schedule {id} completed"));
} else {
app.log.error(&format!(
"schedule {id} FAILED (exit {code}) — command: {}",
entry.command.join(" ")
));
}
Ok(code)
}
+10 -4
View File
@@ -109,16 +109,22 @@ pub fn run(app: &App) -> Result<i32> {
let scheduled = schedule_locked(&locked);
if !scheduled.is_empty() {
println!(
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
"{} {}",
"locked:".yellow(),
scheduled.len()
crate::tr_fmt!(
"{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
scheduled.len()
)
);
}
if scheduled.len() != locked.len() {
println!(
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
"{} {}",
"deferred:".cyan(),
locked.len() - scheduled.len()
crate::tr_fmt!(
"{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
locked.len() - scheduled.len()
)
);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! service: register an agent as a system service with autostart (issue #55).
//! systemd user unit on Linux, launchd LaunchAgent on macOS, Task Scheduler
//! on Windows. Logs go through the existing 'am start --background' runner.
use super::*;
use crate::automation;
use crate::events::{Event, EventKind};
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use std::process::Command;
pub fn run(app: &App, action: &str, agent: Option<&str>, autostart: bool) -> Result<i32> {
match action {
"install" => {
let name = agent.ok_or_else(|| anyhow!("service install requires an agent name"))?;
install(app, name, autostart)
}
"uninstall" => {
let name = agent.ok_or_else(|| anyhow!("service uninstall requires an agent name"))?;
uninstall(app, name)
}
"status" => {
let name = agent.ok_or_else(|| anyhow!("service status requires an agent name"))?;
status(app, name)
}
_ => bail!("usage: am service install|uninstall|status <agent> [--autostart]"),
}
}
fn unit_path(agent: &str) -> PathBuf {
if cfg!(target_os = "linux") {
automation::systemd_unit_path(agent)
} else if cfg!(target_os = "macos") {
automation::launchd_path(agent)
} else {
// Windows: no file — the task lives in the Task Scheduler.
PathBuf::new()
}
}
fn install(app: &App, agent: &str, autostart: bool) -> Result<i32> {
require_agent(app, agent)?;
if app.dry_run() {
app.log
.dry(format!("would install '{agent}' as a system service (autostart: {autostart})"));
return Ok(0);
}
let am_bin = automation::am_bin_quoted();
let mut wrote = String::new();
if cfg!(target_os = "linux") {
let path = automation::systemd_unit_path(agent);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&path, automation::systemd_unit(agent, &am_bin))
.with_context(|| format!("cannot write {}", path.display()))?;
wrote = path.display().to_string();
// Enable + start the user unit.
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
if autostart {
let _ = Command::new("systemctl")
.args(["--user", "enable", &format!("am-{agent}.service")])
.status();
}
let _ = Command::new("systemctl")
.args(["--user", "start", &format!("am-{agent}.service")])
.status();
} else if cfg!(target_os = "macos") {
let path = automation::launchd_path(agent);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&path, automation::launchd_plist(agent, &am_bin))
.with_context(|| format!("cannot write {}", path.display()))?;
wrote = path.display().to_string();
if autostart {
let _ = Command::new("launchctl")
.args(["load", path.to_str().unwrap_or_default()])
.status();
}
} else {
// Windows: prefer a logon task. /sc onlogon needs admin on some
// setups; fall back to the Startup folder (no admin required).
let task = automation::windows_task_name(agent);
let am_bin = std::env::current_exe().unwrap_or_default();
let tr = format!("\"{}\" start {} --background --notify", am_bin.display(), agent);
let status = Command::new("schtasks")
.args(["/create", "/tn", &task, "/sc", "onlogon", "/tr", &tr, "/f"])
.status()?;
if status.success() {
wrote = format!("Task Scheduler: {task}");
} else {
let startup = crate::config::home_dir()
.map(|h| {
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
})
.unwrap_or_default();
let script = startup.join(format!("am-{agent}.cmd"));
if let Some(p) = script.parent() {
std::fs::create_dir_all(p)
.with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&script, format!("@echo off\r\nstart \"\" /b {tr}\r\n"))
.with_context(|| format!("cannot write {}", script.display()))?;
wrote = format!("Startup folder: {}", script.display());
}
}
app.emit(
&Event::now(EventKind::Config)
.with_agent(agent.to_string())
.with_reason(format!("service install (autostart: {autostart})")),
);
app.log.success(&format!(
"service '{agent}' installed{} ({wrote})",
if autostart { " with autostart" } else { "" }
));
Ok(0)
}
fn uninstall(app: &App, agent: &str) -> Result<i32> {
if app.dry_run() {
app.log.dry(format!("would remove the service for '{agent}'"));
return Ok(0);
}
let mut removed = String::new();
if cfg!(target_os = "linux") {
let _ = Command::new("systemctl")
.args(["--user", "stop", &format!("am-{agent}.service")])
.status();
if automation::systemd_unit_path(agent).exists() {
let _ = Command::new("systemctl")
.args(["--user", "disable", &format!("am-{agent}.service")])
.status();
std::fs::remove_file(automation::systemd_unit_path(agent))?;
}
let _ = Command::new("systemctl").args(["--user", "daemon-reload"]).status();
removed = "systemd unit removed".to_string();
} else if cfg!(target_os = "macos") {
let path = automation::launchd_path(agent);
if path.exists() {
let _ = Command::new("launchctl").args(["unload", path.to_str().unwrap_or_default()]).status();
std::fs::remove_file(&path)?;
}
removed = "launchd plist removed".to_string();
} else {
let task = automation::windows_task_name(agent);
let _ = Command::new("schtasks")
.args(["/delete", "/tn", &task, "/f"])
.status();
let mut parts = vec![format!("Task Scheduler: {task}")];
// Also remove the Startup folder fallback if present.
if let Some(h) = crate::config::home_dir() {
let script = h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
.join(format!("am-{agent}.cmd"));
if script.exists() {
std::fs::remove_file(&script).with_context(|| {
format!("cannot remove {}", script.display())
})?;
parts.push("Startup script removed".to_string());
}
}
removed = parts.join(" · ");
}
app.emit(
&Event::now(EventKind::Config)
.with_agent(agent.to_string())
.with_reason("service uninstall"),
);
app.log.success(&format!("service '{agent}' removed ({removed})"));
Ok(0)
}
fn status(app: &App, agent: &str) -> Result<i32> {
let mut installed = false;
if cfg!(target_os = "linux") {
installed = automation::systemd_unit_path(agent).exists();
} else if cfg!(target_os = "macos") {
installed = automation::launchd_path(agent).exists();
} else {
let task = automation::windows_task_name(agent);
let status = Command::new("schtasks")
.args(["/query", "/tn", &task])
.status()
.unwrap_or_default();
installed = status.success();
if !installed {
// Startup folder fallback?
installed = crate::config::home_dir()
.map(|h| {
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
.join(format!("am-{agent}.cmd"))
.exists()
})
.unwrap_or(false);
}
}
if installed {
println!("service '{agent}': installed");
Ok(0)
} else {
app.log.info(&format!("service '{agent}': not installed"));
Ok(1)
}
}
+97 -24
View File
@@ -4,21 +4,22 @@ use super::*;
use crate::output::{print_json, status_style, Table};
use crate::sessions::{self, SessionRecord};
use serde::Serialize;
use std::path::PathBuf;
#[derive(Serialize)]
struct SessionRow {
id: String,
kind: String,
agent: Option<String>,
status: String,
pid: Option<u32>,
cwd: Option<String>,
project: Option<String>,
started_at: String,
ended_at: Option<String>,
duration_s: Option<u64>,
exit_code: Option<i32>,
log: Option<String>,
pub struct SessionRow {
pub id: String,
pub kind: String,
pub agent: Option<String>,
pub status: String,
pub pid: Option<u32>,
pub cwd: Option<String>,
pub project: Option<String>,
pub started_at: String,
pub ended_at: Option<String>,
pub duration_s: Option<u64>,
pub exit_code: Option<i32>,
pub log: Option<String>,
}
fn row_of(r: &SessionRecord) -> SessionRow {
@@ -38,21 +39,14 @@ fn row_of(r: &SessionRecord) -> SessionRow {
}
}
pub fn run(
/// Rows for the web API (issue #54): same shape as `am sessions --json`.
/// Unlike `run`, it never reconciles the registry (the web is read-only).
pub fn list_rows(
app: &App,
agent: Option<&str>,
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
resume: Option<&str>,
) -> Result<i32> {
if let Some(id) = show {
return show_one(app, id);
}
if let Some(id) = resume {
return resume_session(app, id);
}
sessions::reconcile(app)?;
) -> Result<Vec<SessionRow>> {
let sf = sessions::load(app)?;
let mut rows: Vec<SessionRow> = sf
.sessions
@@ -63,6 +57,34 @@ pub fn run(
.map(row_of)
.collect();
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(rows)
}
pub fn run(
app: &App,
agent: Option<&str>,
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
resume: Option<&str>,
export: Option<&str>,
output: Option<&str>,
retention: Option<u64>,
) -> Result<i32> {
if let Some(id) = export {
return export_one(app, id, output);
}
if let Some(days) = retention {
return purge(app, days);
}
if let Some(id) = show {
return show_one(app, id);
}
if let Some(id) = resume {
return resume_session(app, id);
}
sessions::reconcile(app)?;
let rows = list_rows(app, agent, project, status)?;
if app.json() {
print_json(&rows);
return Ok(0);
@@ -181,6 +203,57 @@ fn short_id(id: &str) -> String {
id.chars().take(15).collect()
}
/// Issue #53: 'am sessions export <id> [--output <file>]' — write a
/// complete, self-contained JSON export (metadata, commands, log excerpt)
/// plus a human-readable rendering. Default destination:
/// ./am-session-<id>.json in the current directory.
fn export_one(app: &App, id: &str, output: Option<&str>) -> Result<i32> {
let export = sessions::export_session(app, id)?;
let dest = match output {
Some(o) => PathBuf::from(o),
None => std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!("am-session-{}.json", export.session.id)),
};
let text = serde_json::to_string_pretty(&export)?;
std::fs::write(&dest, text).map_err(|e| {
anyhow::anyhow!("cannot write export {}: {e}", dest.display())
})?;
app.log.success(&format!(
"exported session {} ({} command(s), {} log bytes) to {}",
export.session.id,
export.commands.len(),
export.log_excerpt.len(),
dest.display()
));
app.log.info(&format!(
"JSON complet + rendu lisible ('readable') — compatible archivage manuel / am import"
));
Ok(0)
}
/// Issue #53: 'am sessions --retention <days>' — purge finished sessions
/// older than the threshold. The primary event journal is untouched; the
/// purge is journaled as EventKind::Prune.
fn purge(app: &App, days: u64) -> Result<i32> {
if days == 0 {
bail!("retention must be at least 1 day");
}
if app.dry_run() {
app.log
.dry(format!("would purge sessions finished more than {days} days ago"));
return Ok(0);
}
let purged = sessions::purge_expired(app, days)?;
match purged {
0 => app.log.info(&format!(
"no session finished more than {days} days ago — nothing to purge"
)),
n => app.log.success(&format!("purged {n} session(s) older than {days} days")),
}
Ok(0)
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
+73 -6
View File
@@ -119,12 +119,25 @@ pub fn report_md(app: &App, days: u32) -> String {
}
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
let top_rows = top(app, None).unwrap_or_default();
let mut md = format!(
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
duration
);
// Issue #74: the report follows the active language.
let md = if app.lang().is_en() {
format!(
"# agent-manager report — last {days} days\n\n- Launches: {total_launches}\n- Failures: {failures}\n- Changes (install/update/uninstall): {changes}\n- Cumulative duration: {}s\n- Recorded sessions: {sessions}\n",
duration
)
} else {
format!(
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
duration
)
};
let mut md = md;
if !top_rows.is_empty() {
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
md.push_str(if app.lang().is_en() {
"\n## Top agents\n\n| Agent | Launches | Duration | Failures |\n|---|---|---|---|\n"
} else {
"\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n"
});
for r in &top_rows {
md.push_str(&format!(
"| {} | {} | {}s | {} |\n",
@@ -132,10 +145,30 @@ pub fn report_md(app: &App, days: u32) -> String {
));
}
}
// Issue #49: cost section when usage was recorded.
if let Ok(rows) = crate::costs::aggregate(app, Some(&format!("{days}d"))) {
if !rows.is_empty() {
let t = crate::costs::totals(&rows);
md.push_str(&format!(
"{}\n\n- {}: {}\n- {}: {} in / {} out\n- {}: ${:.4}\n",
if app.lang().is_en() { "\n## Estimated costs" } else { "\n## Coûts estimés" },
if app.lang().is_en() { "Billed sessions" } else { "Sessions facturées" },
t.sessions,
if app.lang().is_en() { "Tokens" } else { "Tokens" },
t.tokens_in,
t.tokens_out,
if app.lang().is_en() { "Estimated cost" } else { "Coût estimé" },
t.cost_usd
));
}
}
md
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>, costs: bool) -> Result<i32> {
if costs {
return run_costs(app, period);
}
let rows = aggregate(app, agent, period)?;
if app.json() {
print_json(&rows);
@@ -201,6 +234,40 @@ pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
Ok(0)
}
/// am stats --costs : token usage and estimated spend (issue #49).
pub fn run_costs(app: &App, period: Option<&str>) -> Result<i32> {
let rows = crate::costs::aggregate(app, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
if rows.is_empty() {
app.log
.info("no cost recorded yet — agents exposing their usage (claude-code) are priced at session end");
return Ok(0);
}
let total = crate::costs::totals(&rows);
let mut table = Table::new(vec!["AGENT", "SESSIONS", "TOKENS IN", "TOKENS OUT", "COST $"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
r.sessions.to_string(),
r.tokens_in.to_string(),
r.tokens_out.to_string(),
format!("{:.4}", r.cost_usd),
]);
}
table.row(vec![
"TOTAL".to_string(),
total.sessions.to_string(),
total.tokens_in.to_string(),
total.tokens_out.to_string(),
format!("{:.4}", total.cost_usd),
]);
print!("{}", table.render());
Ok(0)
}
/// am report : write the markdown digest (issue #37).
pub fn run_report(
app: &App,
+237
View File
@@ -0,0 +1,237 @@
//! suggest: recommend agents for a natural-language request (issue #61,
//! axe 7). Reuses the catalog fuzzy scoring (name/title/description/
//! category/tags) and boosts the ranking with real usage from the event
//! journal (launch counts and durations), with a short justification.
use super::*;
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
/// Top N suggestions shown.
const TOP_N: usize = 5;
#[derive(Serialize)]
struct SuggestRow {
name: String,
display_name: String,
category: Option<String>,
/// Combined score (fuzzy * usage boost).
score: f64,
/// Why this agent was suggested.
reason: String,
/// Real usage in the journal (when available).
launches: u64,
total_duration_s: u64,
}
pub fn run(app: &App, query: &str) -> Result<i32> {
let query = query.trim();
if query.is_empty() {
anyhow::bail!("empty query — describe what you need, e.g. 'am suggest un agent pour du Python'");
}
// 1. Fuzzy scoring over the catalog (name x3, title x2, rest x1).
let scored: Vec<(u32, &AgentDef)> = app
.catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter_map(|a| {
let kw = query.to_lowercase();
let name = a.name.to_lowercase();
let title = a.title().to_lowercase();
let rest = format!(
"{} {} {}",
a.description.as_deref().unwrap_or("").to_lowercase(),
a.category.as_deref().unwrap_or("").to_lowercase(),
a.tags.join(" ").to_lowercase()
);
let score = score_token(&kw, &name)
.saturating_mul(3)
.saturating_add(score_token(&kw, &title).saturating_mul(2))
.saturating_add(score_token(&kw, &rest));
(score > 0).then_some((score, a))
})
.collect();
// 2. Real usage boost from the journal (stats_cmd aggregates launches
// and durations per agent).
let usage = crate::commands::stats_cmd::aggregate(app, None, None).unwrap_or_default();
let launches: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.launches))
.collect();
let duration: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.total_duration_s))
.collect();
let mut rows: Vec<SuggestRow> = scored
.iter()
.map(|(score, a)| {
let l = launches.get(a.name.as_str()).copied().unwrap_or(0);
let d = duration.get(a.name.as_str()).copied().unwrap_or(0);
// Boost: up to +50 % for the most used agents (log scale).
let boost = if l > 0 {
1.0 + 0.5 * (1.0 + (l as f64).ln()).min(2.0) / 2.0
} else {
1.0
};
let final_score = (*score as f64) * boost;
let reason = justify(a, l, d);
SuggestRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
score: final_score,
reason,
launches: l,
total_duration_s: d,
}
})
.collect();
rows.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.name.cmp(&b.name))
});
rows.truncate(TOP_N);
if app.json() {
print_json(&rows);
return Ok(0);
}
if rows.is_empty() {
let suggestions = app.catalog.suggest(query);
if suggestions.is_empty() {
app.log.info(&format!(
"no agent matches '{query}' — try 'am list --all' to browse the catalog"
));
} else {
app.log.info(&format!(
"{} — {} : {} ?",
crate::tr_fmt!("no agent matches '{}'", query),
crate::i18n::tr("vouliez-vous dire"),
suggestions.join(", ")
));
}
return Ok(0);
}
println!(
"{} \"{query}\":",
crate::i18n::tr("suggestions for")
);
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.category.clone().unwrap_or_else(|| "—".to_string()),
r.reason.clone(),
]);
}
print!("{}", table.render());
if rows.len() < scored.len() {
println!(
"\n ({} other match(es) not shown — 'am search {}' lists them)",
scored.len() - rows.len(),
query
);
}
Ok(0)
}
/// Short justification: matched field(s) + real usage when available.
fn justify(a: &AgentDef, launches: u64, duration_s: u64) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = &a.category {
parts.push(format!("catégorie {c}"));
}
if !a.tags.is_empty() {
parts.push(format!("tags: {}", a.tags.join(", ")));
}
if launches > 0 {
parts.push(format!("{launches} lancement(s)"));
if duration_s >= 3600 {
parts.push(format!("{}h", duration_s / 3600));
}
}
if parts.is_empty() {
a.description
.as_deref()
.map(|d| {
let mut d = d.to_string();
d.truncate(60);
d
})
.unwrap_or_else(|| "correspond à la requête".to_string())
} else {
parts.join(" · ")
}
}
/// Reuse the catalog scoring helper (exported from catalog.rs).
fn score_token(keyword: &str, hay: &str) -> u32 {
crate::catalog::score_keyword(keyword, hay)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(name: &str, desc: &str, tags: &[&str]) -> AgentDef {
AgentDef {
name: name.to_string(),
display_name: None,
description: Some(desc.to_string()),
category: Some("coding-agent".to_string()),
website: None,
install: None,
dependencies: vec![],
run: Some(name.to_string()),
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: None,
model_arg: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false,
note: None,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
#[test]
fn scoring_prefers_relevant_agents() {
let a = agent("python-mate", "assistant python", &["python"]);
let b = agent("rust-mate", "assistant rust", &["rust"]);
let catalog = crate::catalog::Catalog::new(
vec![a, b],
BTreeMap::new(),
BTreeMap::new(),
);
let kw = "python";
let mut scored: Vec<(&str, u32)> = catalog
.agents()
.iter()
.filter_map(|x| {
let s = score_token(kw, &x.name.to_lowercase())
.saturating_mul(3)
.saturating_add(score_token(kw, &x.title().to_lowercase()).saturating_mul(2))
.saturating_add(score_token(
kw,
&format!("{} {}", x.description.clone().unwrap_or_default(), x.tags.join(" ")),
));
(s > 0).then_some((x.name.as_str(), s))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
assert_eq!(scored[0].0, "python-mate");
}
}
+26
View File
@@ -0,0 +1,26 @@
//! sync: push the runtime state (state.json, journal, history) into the
//! configured git repository (issue #66). Secrets are excluded by the
//! managed .gitignore; conflicts resolve "last writer wins" with a backup.
use super::*;
pub fn run(app: &App, message: Option<&str>) -> Result<i32> {
let msg = message.unwrap_or("am sync — state update").to_string();
match crate::sync::sync(app, &msg)? {
crate::sync::SyncOutcome::Skipped => {
app.log.info(
"no settings.sync_repo configured — add 'sync_repo: <url>' to the config (issue #66)",
);
Ok(0)
}
crate::sync::SyncOutcome::Clean => {
app.log.info("nothing to sync — state already committed");
Ok(0)
}
crate::sync::SyncOutcome::Pushed { files, message } => {
app.log
.success(&format!("state pushed ({files} file(s)): {message}"));
Ok(0)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
//! theme: switch the active color theme and persist it for the next start.
//!
//! With no argument, prints the current theme and the available list.
//! With a name, switches to that theme (also used by the REPL 'theme' command).
use super::*;
use crate::theme;
pub fn run(app: &App, name: Option<&str>) -> Result<i32> {
if let Some(name) = name {
let theme = theme::find(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown theme '{name}' — available: {}",
theme::names().join(", ")
)
})?;
app.set_theme(theme);
crate::commands::config_cmd::persist_setting(app, "settings.theme", theme.name)?;
app.log.success(&format!(
"theme set to {} ({})",
theme.name,
theme.title
));
return Ok(0);
}
// No argument: show current and list.
let current = app.theme();
if app.json() {
crate::output::print_json(&serde_json::json!({
"current": current.name,
"title": current.title,
"available": theme::names(),
}));
} else {
println!("current theme: {} — {}", current.name, current.title);
println!("available themes: {}", theme::names().join(", "));
println!("use 'am theme <name>' to switch");
}
Ok(0)
}
+742
View File
@@ -0,0 +1,742 @@
//! 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: "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());
}
}
}
}
+105 -1
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists.
use super::*;
use crate::backup;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::{Runner, SystemRunner};
@@ -9,15 +10,45 @@ use anyhow::{bail, Result};
use std::cmp::Ordering;
use std::path::PathBuf;
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
pub fn run(
app: &App,
agent_name: Option<&str>,
all: bool,
rollback: Option<&str>,
) -> Result<i32> {
// Issue #64: --rollback restores a pre-update backup instead of updating.
if rollback.is_some() {
return rollback_point(app, rollback);
}
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
let policy = app.config.settings.update_policy.unwrap_or_default();
if policy == UpdatePolicy::None && all {
app.log.warn("update policy is 'none'; 'am update --all' is a no-op");
return Ok(0);
}
if let Some(name) = agent_name {
if policy == UpdatePolicy::None && !app.yes() {
app.log.warn("update policy is 'none'; use --yes to force a single-agent update");
}
return update_one(app, &runner, name);
}
if all {
let entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0;
// Issue #64: one snapshot covers the whole batch.
if !app.dry_run() && !names.is_empty() {
let info = backup::create_backup(app, "update:--all", &names)?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_reason("update:--all"),
);
}
for name in names {
if let Err(e) = update_one(app, &runner, &name) {
failed += 1;
@@ -31,6 +62,30 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
// Issue #65: honor per-agent pinned version.
if let Some(pin) = agent.pin_version.as_deref() {
let current = app
.state
.get(&agent.name)
.ok()
.flatten()
.and_then(|e| e.version.clone());
if current.as_deref() == Some(pin) {
app.log.success(&format!(
"{} is pinned to v{pin} and already at that version",
agent.title()
));
} else {
app.log.warn(&format!(
"{} is pinned to v{pin} but currently at {}; update skipped",
agent.title(),
current.as_deref().unwrap_or("unknown")
));
}
return Ok(0);
}
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
bail!(
"agent '{}' is not installed by agent-manager — use 'am install {}'",
@@ -90,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0);
}
// Issue #64: snapshot before the real update so a broken upgrade can be
// undone with 'am update --rollback'.
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_agent(agent.name.clone())
.with_reason("pre-update"),
);
crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
@@ -113,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
));
Ok(0)
}
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
/// '--rollback list' prints the available backup points.
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
if point == Some("list") {
let all = backup::list_backups(app);
if all.is_empty() {
app.log.info("no backup found — run an update first to create one");
return Ok(0);
}
println!("pre-update backups ({}):", all.len());
for b in &all {
println!(
" {} {} agents={}{}",
b.id,
b.reason,
if b.agents.is_empty() {
"all".to_string()
} else {
b.agents.join(",")
},
if b.state && b.config { " [state+config]" } else { "" }
);
}
println!("\nuse 'am update --rollback <id>' to restore one");
return Ok(0);
}
let info = backup::restore_backup(app, point)?;
app.emit(
&Event::now(EventKind::Rollback)
.with_args(vec![info.id.clone()])
.with_reason(&info.reason),
);
app.log.success(&format!(
"rolled back to backup {} ({})",
info.id, info.reason
));
Ok(0)
}
+7
View File
@@ -0,0 +1,7 @@
//! web: serve the local read-only dashboard (issue #54).
use super::*;
pub fn run(app: &App, port: Option<u16>, no_open: bool) -> Result<i32> {
crate::web::run(app, port.unwrap_or(crate::web::DEFAULT_PORT), !no_open)
}
+241 -8
View File
@@ -102,6 +102,113 @@ pub struct Settings {
/// lines (global; project profiles can add their own).
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
/// Update policy: how aggressively 'am update' should act (issue #65).
#[serde(default)]
pub update_policy: Option<UpdatePolicy>,
/// A model is a prune candidate after this many days without use
/// (issue #73, default 30).
#[serde(default)]
pub models_prune_days: Option<u64>,
/// Base URL of the official catalog used by 'am catalog update'
/// (issue #60; default: the embedded catalog on Gitea).
#[serde(default)]
pub catalog_url: Option<String>,
/// How long (seconds) a remote catalog stays cached before a refresh
/// is attempted (issue #60, default 3600).
#[serde(default)]
pub catalog_cache_ttl_secs: Option<u64>,
/// How many pre-update backups 'am update --rollback' keeps
/// (issue #64, default 5).
#[serde(default)]
pub backups_keep: Option<u32>,
/// Purge sessions finished more than N days ago (issue #53, default 90).
#[serde(default)]
pub sessions_retention_days: Option<u64>,
/// Token price models used to estimate session costs (issue #49).
#[serde(default)]
pub cost_models: Option<BTreeMap<String, CostModel>>,
/// CPU/memory thresholds for 'am monitor' alerts (issue #50).
#[serde(default)]
pub monitor_thresholds: Option<MonitorThresholds>,
/// Git repository where 'am sync' pushes the state (issue #66).
#[serde(default)]
pub sync_repo: Option<String>,
/// Push automatically when the REPL exits (issue #66, opt-in).
#[serde(default)]
pub sync_on_exit: Option<bool>,
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
}
/// Plugin scripts settings (issue #75): how long one plugin may run and
/// which plugins of the directory are enabled (empty = every plugin).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PluginSettings {
/// Default timeout in seconds for every plugin (default 10).
#[serde(default)]
pub timeout_secs: Option<u64>,
/// Only run these plugin names (empty = all the plugins of the dir).
#[serde(default)]
pub enabled: Option<Vec<String>>,
}
/// Token price model: USD per million tokens (issue #49).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostModel {
pub input_per_mtok: f64,
pub output_per_mtok: f64,
}
/// Default price sheet (USD / MTok) used when settings.cost_models is absent.
pub fn default_cost_models() -> BTreeMap<String, CostModel> {
let mut m = BTreeMap::new();
m.insert("claude-sonnet-4".into(), CostModel { input_per_mtok: 3.0, output_per_mtok: 15.0 });
m.insert("claude-opus-4".into(), CostModel { input_per_mtok: 15.0, output_per_mtok: 75.0 });
m.insert("claude-haiku-3.5".into(), CostModel { input_per_mtok: 0.8, output_per_mtok: 4.0 });
m.insert("gpt-4o".into(), CostModel { input_per_mtok: 2.5, output_per_mtok: 10.0 });
m.insert("gpt-4o-mini".into(), CostModel { input_per_mtok: 0.15, output_per_mtok: 0.6 });
m.insert("deepseek-chat".into(), CostModel { input_per_mtok: 0.27, output_per_mtok: 1.10 });
m.insert("llama-3.1-8b".into(), CostModel { input_per_mtok: 0.05, output_per_mtok: 0.05 });
m
}
/// Alert thresholds for 'am monitor' (issue #50).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MonitorThresholds {
/// Alert when a managed process exceeds this CPU % (0 = disabled).
#[serde(default)]
pub cpu_pct: Option<f64>,
/// Alert when a managed process exceeds this RSS in MB (0 = disabled).
#[serde(default)]
pub mem_mb: Option<u64>,
}
/// Policy controlling how aggressively 'am update' upgrades installed agents.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UpdatePolicy {
/// Never update automatically; 'am update --all' is a no-op.
None,
/// Default: update only when explicitly requested by name or with --all.
#[default]
Manual,
/// Update only to the next patch/minor that is known safe (future use).
Safe,
/// Always update to the latest available version.
Latest,
}
impl UpdatePolicy {
pub fn as_str(&self) -> &'static str {
match self {
UpdatePolicy::None => "none",
UpdatePolicy::Manual => "manual",
UpdatePolicy::Safe => "safe",
UpdatePolicy::Latest => "latest",
}
}
}
/// A project profile: everything am needs to know about one project.
@@ -172,6 +279,16 @@ pub struct AgentDef {
/// Known version (otherwise detected via --version).
#[serde(default)]
pub version: Option<String>,
/// Pinned version: 'am update' will refuse to install a different one (issue #65).
#[serde(default)]
pub pin_version: Option<String>,
/// Environment variable carrying the model name at launch
/// (issue #71, e.g. ANTHROPIC_MODEL for claude-code).
#[serde(default)]
pub model_env: Option<String>,
/// CLI flag carrying the model name at launch (issue #71, e.g. "--model").
#[serde(default)]
pub model_arg: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally.
@@ -186,6 +303,50 @@ pub struct AgentDef {
/// Restrict the agent to platforms: linux, macos, windows (empty = all).
#[serde(default)]
pub platforms: Vec<String>,
/// Health check waited before the next orchestration step (issue #57).
#[serde(default)]
pub healthcheck: Option<HealthCheck>,
/// Container profile used by 'am run --container' (issue #58).
#[serde(default)]
pub container: Option<ContainerSpec>,
/// Price model key (settings.cost_models) used to estimate session costs
/// (issue #49; defaults to the first model of the price sheet).
#[serde(default)]
pub cost_model: Option<String>,
}
/// Health check for group orchestration (issue #57): a command that must
/// exit 0, or a TCP port that must accept connections, within timeout_secs.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HealthCheck {
/// Shell command to probe (exit 0 = healthy).
#[serde(default)]
pub command: Option<String>,
/// TCP port to connect to (accepts = healthy).
#[serde(default)]
pub port: Option<u16>,
/// Seconds to wait before declaring failure (default 30).
#[serde(default)]
pub timeout_secs: Option<u64>,
}
/// Container profile (issue #58): image, volumes, network and env applied
/// by 'am run <agent> --container'.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ContainerSpec {
/// Container image (required).
pub image: String,
/// Extra volumes "host:container[:ro]" on top of state_dir/log_dir.
#[serde(default)]
pub volumes: Vec<String>,
/// Network (default: host on Linux, bridge elsewhere).
#[serde(default)]
pub network: Option<String>,
/// Extra environment variables inside the container.
#[serde(default)]
pub env: BTreeMap<String, String>,
}
fn default_true() -> bool {
@@ -481,7 +642,10 @@ pub fn parse_file(path: &Path) -> Result<Config> {
fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canon.clone()) {
return Err(anyhow!("config include cycle detected at {}", path.display()));
return Err(anyhow!(
"config include cycle detected at {}",
path.display()
));
}
let mut cfg = parse_file(path)?;
let includes = cfg.include.clone();
@@ -491,19 +655,55 @@ fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Resul
let mut merged = Config::default();
merged.version = cfg.version.clone();
for inc in &includes {
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
let sub = load_file_with_includes(&abs, visited)?;
let sub = load_include(inc, base_dir, visited)?;
merge(&mut merged, sub);
}
merge(&mut merged, cfg);
Ok(merged)
}
/// Load one include entry: a local path (relative to the including file) or
/// a remote catalog URL (http/https/file, cached on disk, issue #67).
fn load_include(inc: &str, base_dir: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let lower = inc.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let state_dir = crate::app::state_dir();
let text = crate::catalog_remote::fetch_for_config(&state_dir, inc)?;
let sub = crate::catalog_remote::parse_catalog(&text)
.with_context(|| format!("remote include {inc} is not a valid catalog"))?;
// Remote includes can themselves reference other URLs; local paths
// inside them are resolved against the config directory.
let mut merged = Config::default();
merged.version = sub.version.clone();
for sub_inc in &sub.include {
let nested = load_include(sub_inc, base_dir, visited)?;
merge(&mut merged, nested);
}
let mut own = sub;
own.include.clear();
merge(&mut merged, own);
return Ok(merged);
}
if lower.starts_with("file://") {
let local = inc.strip_prefix("file://").unwrap_or(inc);
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
let local = if cfg!(windows) && local.starts_with('/') {
local.strip_prefix('/').unwrap_or(local)
} else {
local
};
let abs = PathBuf::from(local);
return load_file_with_includes(&abs, visited);
}
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
load_file_with_includes(&abs, visited)
}
/// Merge an overlay config into a base config: user definitions replace
/// default agents with the same name; maps are merged key by key.
pub fn merge(base: &mut Config, overlay: Config) {
@@ -536,6 +736,39 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.theme.is_some() {
s.theme = o.theme;
}
if o.update_policy.is_some() {
s.update_policy = o.update_policy;
}
if o.models_prune_days.is_some() {
s.models_prune_days = o.models_prune_days;
}
if o.catalog_url.is_some() {
s.catalog_url = o.catalog_url;
}
if o.catalog_cache_ttl_secs.is_some() {
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs;
}
if o.backups_keep.is_some() {
s.backups_keep = o.backups_keep;
}
if o.sessions_retention_days.is_some() {
s.sessions_retention_days = o.sessions_retention_days;
}
if o.cost_models.is_some() {
s.cost_models = o.cost_models;
}
if o.monitor_thresholds.is_some() {
s.monitor_thresholds = o.monitor_thresholds;
}
if o.sync_repo.is_some() {
s.sync_repo = o.sync_repo;
}
if o.sync_on_exit.is_some() {
s.sync_on_exit = o.sync_on_exit;
}
if o.plugins.is_some() {
s.plugins = o.plugins;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
+323
View File
@@ -0,0 +1,323 @@
//! Cost tracking (issue #49): collect token usage exposed by agents, price
//! it against the configured model sheet, and aggregate per agent/session.
//! No runtime dependency is added — everything is std + the existing config.
use crate::config::{self, CostModel};
use crate::events::{self, Event, EventKind};
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Token usage collected from an agent.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
pub struct Usage {
pub tokens_in: u64,
pub tokens_out: u64,
}
/// One aggregated cost row (per agent, or global totals).
#[derive(Debug, Clone, Default, Serialize)]
pub struct CostStats {
pub agent: String,
pub sessions: u64,
pub tokens_in: u64,
pub tokens_out: u64,
pub cost_usd: f64,
}
/// Price the usage against a model of the sheet (issue #49).
/// Unknown models fall back to the first model of the sheet.
pub fn estimate_cost(
model: Option<&str>,
usage: Usage,
sheet: &BTreeMap<String, CostModel>,
) -> f64 {
let m = model
.and_then(|k| sheet.get(k))
.or_else(|| sheet.values().next());
match m {
Some(cm) => {
usage.tokens_in as f64 * cm.input_per_mtok / 1_000_000.0
+ usage.tokens_out as f64 * cm.output_per_mtok / 1_000_000.0
}
None => 0.0,
}
}
/// Collect the usage an agent exposed for its latest session (issue #49).
///
/// Supported sources today:
/// - Claude Code: `~/.claude/projects/*/<session>.jsonl` — the last JSON
/// line carrying an `usage` object with `input_tokens`/`output_tokens`.
///
/// Returns None when the agent exposes nothing (safe, silent).
pub fn collect_usage(agent: &str, cwd: Option<&str>) -> Option<Usage> {
match agent {
"claude-code" => claude_usage(cwd),
_ => None,
}
}
/// Claude Code stores one JSONL per session under ~/.claude/projects/<enc>/.
/// The final line with a "usage" object holds the cumulative token counts.
fn claude_usage(cwd: Option<&str>) -> Option<Usage> {
let root = config::home_dir()?.join(".claude/projects");
let mut candidates: Vec<(String, PathBuf)> = Vec::new();
for entry in std::fs::read_dir(&root).ok()?.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
for f in std::fs::read_dir(&dir).ok()?.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) == Some("jsonl") {
if let Ok(mtime) = f.metadata().and_then(|m| m.modified()) {
candidates.push((mtime_key(mtime), p));
}
}
}
}
candidates.sort();
let last = candidates.last()?.1.clone();
parse_claude_file(&last, cwd)
}
fn mtime_key(t: std::time::SystemTime) -> String {
format!(
"{:?}",
t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default()
)
}
/// Parse a Claude Code session file: the last line whose "usage" object has
/// input/output token counts. Filters by cwd when given (the file embeds it
/// as `cwd` on summary lines).
fn parse_claude_file(path: &std::path::Path, cwd: Option<&str>) -> Option<Usage> {
let text = std::fs::read_to_string(path).ok()?;
let mut usage = None;
for line in text.lines() {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("usage").is_none() {
continue;
}
if let Some(want) = cwd {
if v.get("cwd").and_then(|c| c.as_str()) != Some(want) {
continue;
}
}
let u = v.get("usage")?;
let tin = u.get("input_tokens").and_then(|n| n.as_u64())?;
let tout = u.get("output_tokens").and_then(|n| n.as_u64())?;
usage = Some(Usage {
tokens_in: tin,
tokens_out: tout,
});
}
usage
}
/// Record a session's usage: persist it on the session record and emit a
/// `cost` event (axe 8 journal). Called when an agent session finishes.
pub fn record_session_usage(
app: &crate::app::App,
agent: &str,
session_id: &str,
cwd: Option<&str>,
) -> Result<()> {
let Some(usage) = collect_usage(agent, cwd) else {
return Ok(());
};
let model = app
.catalog
.resolve(agent)
.and_then(|a| a.cost_model.as_deref());
let sheet = app
.config
.settings
.cost_models
.clone()
.unwrap_or_else(config::default_cost_models);
let cost = estimate_cost(model, usage, &sheet);
// Persist on the session record.
let mut sf = crate::sessions::load(app)?;
if let Some(r) = sf.sessions.get_mut(session_id) {
r.tokens_in = Some(usage.tokens_in);
r.tokens_out = Some(usage.tokens_out);
r.cost_usd = Some(cost);
crate::sessions::save(app, &sf)?;
}
// Journal a dedicated event (stable --json contract, additive fields).
app.emit(
&Event::now(EventKind::Cost)
.with_agent(agent.to_string())
.with_session(session_id)
.with_usage(usage.tokens_in, usage.tokens_out)
.with_cost(cost),
);
Ok(())
}
/// Aggregate cost events into per-agent rows (issue #49). `--json` output
/// stays stable: agent, sessions, tokens_in, tokens_out, cost_usd.
pub fn aggregate(
app: &crate::app::App,
period: Option<&str>,
) -> Result<Vec<CostStats>> {
let cut = crate::commands::stats_cmd::cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, CostStats> = BTreeMap::new();
for ev in events {
if ev.kind != EventKind::Cost {
continue;
}
if let Some(cut) = &cut {
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) {
if ts.with_timezone(&chrono::Utc) < *cut {
continue;
}
}
}
let agent = ev.agent.clone().unwrap_or_else(|| "unknown".to_string());
let row = map.entry(agent.clone()).or_default();
row.agent = agent;
row.sessions += 1;
row.tokens_in += ev.tokens_in.unwrap_or(0);
row.tokens_out += ev.tokens_out.unwrap_or(0);
row.cost_usd += ev.cost_usd.unwrap_or(0.0);
}
Ok(map.into_values().collect())
}
/// Total across every row (used by the weekly/monthly report).
pub fn totals(rows: &[CostStats]) -> CostStats {
let mut t = CostStats {
agent: "total".to_string(),
..Default::default()
};
for r in rows {
t.sessions += r.sessions;
t.tokens_in += r.tokens_in;
t.tokens_out += r.tokens_out;
t.cost_usd += r.cost_usd;
}
t
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estimate_cost_uses_model_prices() {
let sheet = config::default_cost_models();
// 1M input @ $3 + 500k output @ $15 = 3 + 7.5 = $10.5
let u = Usage {
tokens_in: 1_000_000,
tokens_out: 500_000,
};
let c = estimate_cost(Some("claude-sonnet-4"), u, &sheet);
assert!((c - 10.5).abs() < 1e-9, "got {c}");
}
#[test]
fn estimate_cost_falls_back_to_first_model() {
let sheet = config::default_cost_models();
let c = estimate_cost(Some("nope-model"), Usage { tokens_in: 0, tokens_out: 0 }, &sheet);
assert_eq!(c, 0.0);
}
#[test]
fn estimate_cost_unknown_sheet_is_zero() {
let sheet = BTreeMap::new();
let c = estimate_cost(None, Usage { tokens_in: 1000, tokens_out: 1000 }, &sheet);
assert_eq!(c, 0.0);
}
#[test]
fn parse_claude_file_extracts_last_usage() {
let dir = std::env::temp_dir().join(format!("am-costs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("session.jsonl");
std::fs::write(
&f,
concat!(
"{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5}},\"cwd\":\"/x\"}\n",
"{\"type\":\"summary\",\"usage\":{\"input_tokens\":100,\"output_tokens\":40},\"cwd\":\"/x\"}\n",
),
)
.unwrap();
let u = parse_claude_file(&f, Some("/x")).unwrap();
assert_eq!(u.tokens_in, 100);
assert_eq!(u.tokens_out, 40);
// Wrong cwd → filtered out.
assert!(parse_claude_file(&f, Some("/other")).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn aggregate_sums_per_agent() {
let app = test_app("costs-agg");
app.emit(
&Event::now(EventKind::Cost)
.with_agent("a".to_string())
.with_usage(10, 5)
.with_cost(0.5),
);
app.emit(
&Event::now(EventKind::Cost)
.with_agent("a".to_string())
.with_usage(20, 10)
.with_cost(1.0),
);
app.emit(
&Event::now(EventKind::Cost)
.with_agent("b".to_string())
.with_usage(100, 50)
.with_cost(2.0),
);
let rows = aggregate(&app, None).unwrap();
assert_eq!(rows.len(), 2);
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.sessions, 2);
assert_eq!(a.tokens_in, 30);
assert_eq!(a.tokens_out, 15);
assert!((a.cost_usd - 1.5).abs() < 1e-9);
let t = totals(&rows);
assert!((t.cost_usd - 3.5).abs() < 1e-9);
assert_eq!(t.sessions, 3);
}
fn test_app(tag: &str) -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"list",
]);
let mut app = crate::app::App::from_cli(cli).expect("app should build");
app.paths.state_file = dir.path().join(format!("state-{tag}.json"));
app.paths.log_dir = dir.path().join("logs");
app
}
}
+40 -2
View File
@@ -76,8 +76,7 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
let empty = BTreeMap::new();
if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) {
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
let combined = format!("{}\n{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
detail = format!("{} (v{v})", path.display());
}
@@ -113,6 +112,45 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
}
}
// --- local model runtimes (issue #72) -------------------------------
for runtime in ["ollama", "llama-server"] {
if let Some(path) = runner.which(runtime) {
let mut detail = path.display().to_string();
let empty = BTreeMap::new();
if let Ok(out) = runner.run(runtime, &["--version".to_string()], &empty, None, true) {
if out.code == 0 {
let combined = format!("{}\n{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
detail = format!("{} (v{})", path.display(), v);
}
}
}
// For ollama, also check that the server answers.
let status = if runtime == "ollama" {
match runner.run(runtime, &["ls".to_string()], &empty, None, true) {
Ok(out) if out.code == 0 => Status::Ok,
_ => {
detail.push_str(" — server does not answer (ollama serve?)");
Status::Warn
}
}
} else {
Status::Ok
};
checks.push(Check {
name: runtime.to_string(),
status,
detail,
});
} else {
checks.push(Check {
name: runtime.to_string(),
status: Status::Ok,
detail: "not installed (optional)".to_string(),
});
}
}
// --- configuration ---------------------------------------------------
match &app.config_origin {
Some(path) => checks.push(Check {
+51 -2
View File
@@ -32,6 +32,22 @@ pub enum EventKind {
Shell,
/// Personal annotations: favorite, note, tags (issue #39).
Annotate,
/// Local model runtime action: prune of unused models (issue #73).
Model,
/// Remote catalog action: update or add of an external catalog (issue #60).
Catalog,
/// Pre-update backup created (issue #64).
Backup,
/// Rollback of a previous backup (issue #64).
Rollback,
/// Derived index pruned (sessions retention, issue #53).
Prune,
/// Session usage recorded: tokens + estimated cost (issue #49).
Cost,
/// Threshold alert from 'am monitor' (issue #50).
Alert,
/// One benchmark run of 'am lab' (issue #62).
Lab,
}
impl EventKind {
@@ -49,6 +65,14 @@ impl EventKind {
EventKind::Repl => "repl",
EventKind::Shell => "shell",
EventKind::Annotate => "annotate",
EventKind::Model => "model",
EventKind::Catalog => "catalog",
EventKind::Backup => "backup",
EventKind::Rollback => "rollback",
EventKind::Prune => "prune",
EventKind::Cost => "cost",
EventKind::Alert => "alert",
EventKind::Lab => "lab",
}
}
}
@@ -80,6 +104,15 @@ pub struct Event {
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_s: Option<u64>,
/// Input tokens billed for the session (issue #49).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u64>,
/// Output tokens billed for the session (issue #49).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u64>,
/// Estimated cost in USD (issue #49).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
}
impl Event {
@@ -98,6 +131,9 @@ impl Event {
env_keys: Vec::new(),
exit_code: None,
duration_s: None,
tokens_in: None,
tokens_out: None,
cost_usd: None,
}
}
@@ -146,8 +182,21 @@ impl Event {
self
}
pub fn with_duration(mut self, secs: u64) -> Self {
self.duration_s = Some(secs);
pub fn with_duration(mut self, d: u64) -> Self {
self.duration_s = Some(d);
self
}
/// Record token usage (issue #49).
pub fn with_usage(mut self, tokens_in: u64, tokens_out: u64) -> Self {
self.tokens_in = Some(tokens_in);
self.tokens_out = Some(tokens_out);
self
}
/// Record the estimated cost in USD (issue #49).
pub fn with_cost(mut self, usd: f64) -> Self {
self.cost_usd = Some(usd);
self
}
}
+504
View File
@@ -0,0 +1,504 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am — {{Vue d'ensemble}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
}
body {
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
}
.layout { display:flex; min-height:100vh; }
/* ---------- Sidebar ---------- */
.side {
width:225px; flex-shrink:0; background:var(--panel2);
border-right:1px solid var(--border); padding:18px 12px;
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
}
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
.logo-badge {
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
}
.logo b { font-size:16px; letter-spacing:.5px; }
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
.nav-item {
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
}
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
.nav-item .ico { width:18px; text-align:center; }
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; animation:pulse 2.4s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* ---------- Main ---------- */
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
.topbar h1 { font-size:20px; font-weight:700; }
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
.top-actions { display:flex; align-items:center; gap:10px; }
.badge {
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
}
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
.badge.live::before { content:"● "; }
.btn {
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
font-weight:700; font-size:13px; cursor:pointer;
}
.btn:hover { filter:brightness(1.1); }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
/* ---------- KPI cards ---------- */
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
.kpi .delta { font-size:11.5px; margin-top:4px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
/* ---------- Panels ---------- */
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; margin-bottom:14px; }
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); transition:width .5s ease; }
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
canvas#trend-canvas { width:100%; height:150px; display:block; }
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
/* ---------- Tables ---------- */
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
tbody tr:hover { background:rgba(61,220,151,.05); }
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; white-space:nowrap; }
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
.search:focus { outline:none; border-color:var(--accent); }
/* ---------- Timeline ---------- */
.tl { list-style:none; }
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
.view { display:none; } .view.active { display:block; }
.err-bar { background:rgba(248,113,113,.12); border:1px solid rgba(248,113,113,.35); color:var(--err); border-radius:8px; padding:10px 14px; margin-bottom:14px; font-size:12.5px; display:none; }
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
</style>
</head>
<body>
<div class="layout">
<aside class="side">
<div class="logo">
<div class="logo-badge">am</div>
<div><b>agent-manager</b><small>cockpit des agents</small></div>
</div>
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">{{Vue d'ensemble}}</span></div>
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">{{Statistiques}}</span></div>
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">{{Événements}}</span></div>
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">{{Projets}}</span></div>
<div class="side-foot">
<span><span class="dot"></span>v<span id="side-version">—</span> · <span id="side-lang">{{serveur local}}</span></span>
</div>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 id="view-title">{{Vue d'ensemble}}</h1>
<div class="sub"><span>{{Dernière mise à jour}}</span> : <span id="last-update">—</span></div>
</div>
<div class="top-actions">
<span class="badge live">{{serveur local}}</span>
<span class="badge">127.0.0.1</span>
<button class="btn ghost" onclick="loadAll()">↻ {{Actualiser}}</button>
</div>
</div>
<div class="err-bar" id="err-bar"></div>
<!-- ===== OVERVIEW ===== -->
<section class="view active" id="overview">
<div class="kpis">
<div class="kpi"><span class="emoji">🤖</span><div class="label">{{Agents installés}}</div><div class="value" id="kpi-installed">—</div><div class="delta" id="kpi-installed-delta"></div></div>
<div class="kpi"><span class="emoji">⚡</span><div class="label">{{En cours}}</div><div class="value" id="kpi-running">—</div><div class="delta" id="kpi-running-delta"></div></div>
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-sessions">—</div><div class="delta" id="kpi-sessions-delta"></div></div>
<div class="kpi"><span class="emoji">📜</span><div class="label">{{Événements (24 h)}}</div><div class="value" id="kpi-events">—</div><div class="delta" id="kpi-events-delta"></div></div>
</div>
<div class="grid2">
<div class="panel">
<h3>{{Top agents — sessions (7 j)}}</h3>
<div id="top-agents"></div>
</div>
<div class="panel">
<h3>{{Activité — sessions par jour (14 j)}}</h3>
<canvas id="trend-canvas"></canvas>
<div class="trend-axis"><span id="axis-a"></span><span id="axis-b"></span><span id="axis-c"></span></div>
</div>
</div>
<div class="grid2">
<div class="panel">
<h3>{{Activité récente}}</h3>
<ul class="tl" id="recent-activity"></ul>
</div>
<div class="panel">
<h3>{{Sessions en cours}}</h3>
<table>
<thead><tr><th>{{Agent}}</th><th>{{Projet}}</th><th>{{Durée}}</th><th>{{Démarrée}}</th></tr></thead>
<tbody id="running-sessions"></tbody>
</table>
<div class="empty-note" id="running-empty" style="display:none">{{Aucune session en cours}}</div>
</div>
</div>
</section>
<!-- ===== STATS ===== -->
<section class="view" id="stats">
<div class="panel">
<h3>{{Statistiques par agent — période 30 j}}</h3>
<table>
<thead><tr><th>{{Agent}}</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Durée moyenne}}</th><th>{{Échecs}}</th><th>{{Dernière activité}}</th></tr></thead>
<tbody id="stats-body"></tbody>
</table>
<div class="empty-note" id="stats-empty" style="display:none">{{Aucune activité enregistrée}}</div>
</div>
</section>
<!-- ===== SESSIONS ===== -->
<section class="view" id="sessions">
<div class="panel">
<h3>Sessions</h3>
<input class="search" id="sess-search" placeholder="{{Filtrer par agent, projet ou id…}}" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>{{Session}}</th><th>{{Agent}}</th><th>{{Projet}}</th><th>{{Statut}}</th><th>{{Durée}}</th><th>{{Démarrée}}</th><th>{{Fin}}</th></tr></thead>
<tbody id="sessions-body"></tbody>
</table>
<div class="empty-note" id="sessions-empty" style="display:none">{{Aucune session ne correspond au filtre}}</div>
</div>
</section>
<!-- ===== EVENTS ===== -->
<section class="view" id="events">
<div class="panel">
<h3>{{Journal d'événements — aujourd'hui}}</h3>
<ul class="tl" id="events-list"></ul>
<div class="empty-note" id="events-empty" style="display:none">{{Aucun événement}}</div>
</div>
</section>
<!-- ===== PROJECTS ===== -->
<section class="view" id="projects">
<div class="kpis">
<div class="kpi"><span class="emoji">🗂</span><div class="label">{{Projets suivis}}</div><div class="value" id="kpi-projects">—</div><div class="delta"></div></div>
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-proj-sessions">—</div><div class="delta"></div></div>
<div class="kpi"><span class="emoji">🕒</span><div class="label">{{Temps total}}</div><div class="value" id="kpi-proj-time">—</div><div class="delta"></div></div>
</div>
<div class="panel">
<h3>{{Projets}}</h3>
<table>
<thead><tr><th>{{Projet}}</th><th>{{Agent}}</th><th>Sessions</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Dernière activité}}</th></tr></thead>
<tbody id="projects-body"></tbody>
</table>
<div class="empty-note" id="projects-empty" style="display:none">{{Aucun projet suivi}}</div>
</div>
</section>
</main>
</div>
<script>
// Injected by the server: localized status labels (fr/en, gettext keys).
window.I18N = {{i18n_bundle}};
const state = { overview: null, stats: null, sessions: null, events: null, projects: null, health: null };
const $ = (id) => document.getElementById(id);
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fmtTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return String(iso).slice(11, 16);
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function fmtDate(iso) { return iso ? String(iso).slice(0, 10) : '—'; }
function fmtDur(s) {
if (s == null) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h > 0) return h + 'h' + String(m).padStart(2, '0') + 'm';
if (m > 0) return m + 'm';
return s + 's';
}
function shortRoot(root) {
if (!root) return '—';
const parts = String(root).split(/[\\/]/).filter(Boolean);
return esc(parts[parts.length - 1] || root);
}
const STATUS_CLASS = { running: 'run', stopped: 'ok', failed: 'fail', interrupted: 'mut' };
function statusPill(s) {
const cls = STATUS_CLASS[s] || 'ok';
const label = (window.I18N && window.I18N[s]) || s;
return '<span class="pill ' + cls + '">' + esc(label) + '</span>';
}
const KIND_COLORS = {
start:'#38bdf8', run:'#38bdf8', restart:'#38bdf8', cost:'#38bdf8',
stop:'#34d399', shell:'#34d399', repl:'#34d399',
install:'#a78bfa', update:'#a78bfa', uninstall:'#a78bfa', config:'#a78bfa',
annotate:'#a78bfa', backup:'#a78bfa', rollback:'#a78bfa', model:'#a78bfa',
catalog:'#a78bfa', prune:'#a78bfa', lab:'#a78bfa',
doctor:'#f59e0b', alert:'#f87171',
};
function eventDetail(e) {
if (e.args && e.args.length) return esc(e.args.join(' '));
if (e.reason) return esc(e.reason);
return '';
}
async function jget(url) {
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
return r.json();
}
async function loadAll() {
try {
const [ov, st, se, ev, pr, h] = await Promise.all([
jget('/api/overview'), jget('/api/stats'), jget('/api/sessions'),
jget('/api/events?limit=2000'), jget('/api/projects'), jget('/api/health'),
]);
state.overview = ov; state.stats = st; state.sessions = se;
state.events = ev; state.projects = pr; state.health = h;
$('err-bar').style.display = 'none';
$('side-version').textContent = (h && h.version) || '—';
renderAll();
const t = new Date();
$('last-update').textContent = t.toLocaleTimeString();
} catch (e) {
const bar = $('err-bar');
bar.textContent = '{{erreur de chargement}} : ' + e.message;
bar.style.display = 'block';
}
}
function renderAll() {
renderOverview(); renderStats(); renderSessions(); renderEvents(); renderProjects();
}
// ---------- Overview ----------
function renderOverview() {
const ov = state.overview || {};
const counts = ov.counts || {};
$('kpi-installed').textContent = counts.installed != null ? counts.installed : '—';
$('kpi-running').textContent = counts.running != null ? counts.running : '—';
const now = Date.now();
const DAY = 86400000;
const sessions = ov.sessions || [];
const s7 = sessions.filter(s => now - new Date(s.started_at).getTime() <= 7 * DAY).length;
const ev24 = (state.events || []).filter(e => now - new Date(e.ts).getTime() <= DAY).length;
$('kpi-sessions').textContent = s7;
$('kpi-events').textContent = ev24;
$('kpi-sessions-delta').textContent = sessions.length + ' total';
$('kpi-events-delta').textContent = (state.events || []).length + ' total';
// Top agents by launches (7d from the dashboard snapshot).
const stats = (ov.stats || []).slice().sort((a, b) => b.launches - a.launches).slice(0, 6);
const max = Math.max(1, ...stats.map(s => s.launches));
$('top-agents').innerHTML = stats.length
? stats.map(s =>
'<div class="bar-row"><span class="name" title="' + esc(s.agent) + '">' + esc(s.agent) + '</span>' +
'<div class="track"><div class="fill" style="width:' + Math.round(s.launches / max * 100) + '%"></div></div>' +
'<span class="val">' + s.launches + '</span></div>').join('')
: '<div class="empty-note">{{Aucune activité enregistrée}}</div>';
renderTrend();
setAxis();
// Recent activity (newest first, already sorted by the server).
const activity = (ov.activity || []).slice(0, 8);
$('recent-activity').innerHTML = activity.length
? activity.map(a => {
const color = KIND_COLORS[a.kind] || '#8b98a5';
const who = a.agent ? '<span class="a">' + esc(a.agent) + '</span> ' : '';
return '<li><span class="dot" style="background:' + color + '"></span>' +
'<span class="t">' + who + esc(a.detail) + '</span>' +
'<span class="m">' + fmtTime(a.ts) + '</span></li>';
}).join('')
: '<li class="empty-note">{{Aucune activité enregistrée}}</li>';
// Running sessions.
const running = sessions.filter(s => s.status === 'running');
$('running-sessions').innerHTML = running.length
? running.map(s =>
'<tr><td>' + esc(s.agent || s.kind) + '</td><td>' + esc(s.project || '—') + '</td>' +
'<td class="mono">' + fmtDur(s.duration_s) + '</td><td class="mono">' + fmtTime(s.started_at) + '</td></tr>').join('')
: '';
$('running-empty').style.display = running.length ? 'none' : 'block';
}
function setAxis() {
const d = new Date();
const fmt = (off) => { const x = new Date(d); x.setDate(x.getDate() + off); return (x.getMonth() + 1) + '/' + x.getDate(); };
$('axis-a').textContent = fmt(-13);
$('axis-b').textContent = fmt(-7);
$('axis-c').textContent = fmt(0);
}
function renderTrend() {
const canvas = $('trend-canvas');
const wrap = canvas.parentElement;
const W = Math.max(240, wrap.clientWidth - 4) || 480, H = 150;
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const days = [];
const today = new Date();
for (let i = 13; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
days.push({ key, n: 0 });
}
(state.events || []).forEach(e => {
if (e.kind === 'start' || e.kind === 'run') {
const k = String(e.ts).slice(0, 10);
const day = days.find(d => d.key === k);
if (day) day.n++;
}
});
const max = Math.max(1, ...days.map(d => d.n));
const pad = 16, base = H - 26, bw = (W - 2 * pad) / days.length;
// baseline + gridline
ctx.strokeStyle = '#1d2733'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad, base + 0.5); ctx.lineTo(W - pad, base + 0.5); ctx.stroke();
const grad = ctx.createLinearGradient(0, 0, 0, base);
grad.addColorStop(0, '#3ddc97'); grad.addColorStop(1, '#38bdf8');
days.forEach((d, i) => {
const x = pad + i * bw, h = Math.round((d.n / max) * (base - 18));
ctx.fillStyle = grad;
ctx.fillRect(x + 2, base - h, bw - 4, h);
if (d.n > 0) {
ctx.fillStyle = '#0b0f14'; ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(String(d.n), x + bw / 2, base - h - 4);
}
});
}
// ---------- Stats ----------
function renderStats() {
const rows = state.stats || [];
$('stats-body').innerHTML = rows.length
? rows.slice().sort((a, b) => b.launches - a.launches).map(s =>
'<tr><td>' + esc(s.agent) + '</td><td>' + s.launches + '</td>' +
'<td class="mono">' + fmtDur(s.total_duration_s) + '</td>' +
'<td class="mono">' + fmtDur(s.avg_duration_s) + '</td>' +
'<td>' + (s.failures > 0 ? '<span class="pill fail">' + s.failures + '</span>' : '<span class="pill ok">0</span>') + '</td>' +
'<td class="mono">' + fmtDate(s.last_used) + ' ' + fmtTime(s.last_used) + '</td></tr>').join('')
: '';
$('stats-empty').style.display = rows.length ? 'none' : 'block';
}
// ---------- Sessions ----------
function renderSessions() {
const rows = state.sessions || [];
$('sessions-body').innerHTML = rows.length
? rows.map(s =>
'<tr><td class="mono">' + esc(s.id) + '</td><td>' + esc(s.agent || s.kind) + '</td>' +
'<td>' + esc(s.project || '—') + '</td><td>' + statusPill(s.status) + '</td>' +
'<td class="mono">' + fmtDur(s.duration_s) + '</td>' +
'<td class="mono">' + fmtDate(s.started_at) + ' ' + fmtTime(s.started_at) + '</td>' +
'<td class="mono">' + fmtTime(s.ended_at) + '</td></tr>').join('')
: '';
filterSessions(($('sess-search') || {}).value || '');
$('sessions-empty').style.display = 'none';
}
function filterSessions(q) {
q = (q || '').toLowerCase();
let shown = 0;
document.querySelectorAll('#sessions-body tr').forEach(tr => {
const ok = tr.textContent.toLowerCase().includes(q);
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
$('sessions-empty').style.display = shown ? 'none' : 'block';
}
// ---------- Events ----------
function renderEvents() {
const rows = state.events || [];
$('events-list').innerHTML = rows.length
? rows.map(e => {
const color = KIND_COLORS[e.kind] || '#8b98a5';
const who = e.agent ? '<span class="a">' + esc(e.agent) + '</span> ' : '';
return '<li><span class="dot" style="background:' + color + '"></span>' +
'<span class="t"><span class="a">' + esc(e.kind) + '</span> ' + who + eventDetail(e) + '</span>' +
'<span class="m">' + fmtTime(e.ts) + '</span></li>';
}).join('')
: '';
$('events-empty').style.display = rows.length ? 'none' : 'block';
}
// ---------- Projects ----------
function renderProjects() {
const rows = state.projects || [];
$('kpi-projects').textContent = rows.length;
$('kpi-proj-sessions').textContent = rows.reduce((a, p) => a + (p.sessions || 0), 0);
$('kpi-proj-time').textContent = fmtDur(rows.reduce((a, p) => a + (p.total_duration_s || 0), 0));
$('projects-body').innerHTML = rows.length
? rows.map(p =>
'<tr><td><b>' + shortRoot(p.root) + '</b></td>' +
'<td>' + (p.agents || []).map(esc).join(' · ') + '</td>' +
'<td>' + (p.sessions || 0) + '</td><td>' + (p.launches || 0) + '</td>' +
'<td class="mono">' + fmtDur(p.total_duration_s) + '</td>' +
'<td class="mono">' + fmtDate(p.last_activity) + ' ' + fmtTime(p.last_activity) + '</td></tr>').join('')
: '';
$('projects-empty').style.display = rows.length ? 'none' : 'block';
}
// ---------- Navigation ----------
const TITLES = {
overview: "{{Vue d'ensemble}}", stats: "{{Statistiques}}", sessions: "Sessions",
events: "{{Événements}}", projects: "{{Projets}}",
};
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
const v = el.dataset.view;
document.getElementById(v).classList.add('active');
$('view-title').textContent = TITLES[v];
if (v === 'overview') renderTrend(); // canvas needs the visible size
});
});
window.addEventListener('resize', () => { if (state.overview) renderTrend(); });
loadAll();
setInterval(loadAll, 30000);
</script>
</body>
</html>
+368 -44
View File
@@ -105,6 +105,12 @@ const GLOBAL_FLAGS: &[HelpFlag] = &[
value: "THEME",
desc: "Color theme for the output (see 'am help theme' for the list)",
},
HelpFlag {
short: "",
long: "--lang",
value: "LANG",
desc: "Output language: fr (default) or en (issue #74)",
},
];
const START_FLAGS: &[HelpFlag] = &[
@@ -138,6 +144,18 @@ const START_FLAGS: &[HelpFlag] = &[
value: "",
desc: "Send a desktop notification once the agent has started",
},
HelpFlag {
short: "",
long: "--parallel",
value: "",
desc: "Start every group member back-to-back without waiting (issue #57)",
},
HelpFlag {
short: "",
long: "--container",
value: "",
desc: "Run inside the agent's container profile (issue #58)",
},
];
pub static HELP_SPECS: &[HelpSpec] = &[
@@ -181,12 +199,16 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "sessions",
category: "Commands",
usage: "sessions {flags} [agent]",
about: "List the sessions of every agent and of the interactive shell.",
search_terms: &["session", "history", "log"],
about: "List sessions; export one as JSON; enforce retention on old ones.",
search_terms: &["session", "history", "log", "export", "retention", "purge", "resume"],
flags: &[
HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only sessions of this project" },
HelpFlag { short: "", long: "--status", value: "STATUS", desc: "Only sessions with this status (running, stopped, failed, interrupted)" },
HelpFlag { short: "", long: "--show", value: "ID", desc: "Show one session in detail (summary + log excerpt)" },
HelpFlag { short: "", long: "--export", value: "ID", desc: "Export a session (metadata + commands + log excerpt) to JSON" },
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Export destination (default ./am-session-<ID>.json)" },
HelpFlag { short: "", long: "--retention", value: "DAYS", desc: "Purge sessions finished more than N days ago (settings default 90)" },
HelpFlag { short: "", long: "--resume", value: "ID", desc: "Relaunch a finished agent session with its recorded arguments" },
],
subcommands: &[],
parameters: &[
@@ -195,17 +217,57 @@ pub static HELP_SPECS: &[HelpSpec] = &[
io: None,
examples: &[
HelpExample { desc: "List recent sessions.", code: "sessions" },
HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" },
HelpExample { desc: "Export one session.", code: "sessions --export 20260815_143926_a1b2c3" },
HelpExample { desc: "Purge sessions older than 30 days.", code: "sessions --retention 30" },
HelpExample { desc: "Resume a finished session.", code: "sessions --resume 20260815_143926_a1b2c3" },
],
},
HelpSpec {
name: "sync",
category: "Commands",
usage: "sync {flags}",
about: "Push the state (state.json, journal, history) into the configured git repository (issue #66).",
search_terms: &["git", "backup", "push", "sync_repo", "sauvegarde"],
flags: &[
HelpFlag { short: "", long: "--message", value: "MESSAGE", desc: "Commit message (default: 'am sync — state update')" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Commit and push the state.", code: "sync" },
HelpExample { desc: "Custom commit message.", code: "sync --message \"daily backup\"" },
],
},
HelpSpec {
name: "migrate",
category: "Commands",
usage: "migrate {flags} [bundle]",
about: "Transfer the installation and state to another machine: export a .amx bundle, import it and verify with doctor (issue #68).",
search_terms: &["transfer", "machine", "bundle", "export", "import", "migration"],
flags: &[
HelpFlag { short: "", long: "--export", value: "", desc: "Export the bundle (default when no bundle path is given)" },
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Bundle path for export (default agent-manager-migrate.amx)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "bundle", typ: "path", desc: "Bundle to import (with confirmation + doctor post-import)" },
],
io: None,
examples: &[
HelpExample { desc: "Export the bundle.", code: "migrate --export --output backup.amx" },
HelpExample { desc: "Import on machine B.", code: "migrate backup.amx" },
],
},
HelpSpec {
name: "stats",
category: "Commands",
usage: "stats {flags} [agent]",
about: "Show usage statistics computed from the event journal.",
search_terms: &["usage", "metrics", "duration"],
about: "Usage statistics from the event journal; --costs prices the token usage.",
search_terms: &["usage", "cost", "tokens", "spend", "period"],
flags: &[
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" },
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "7d, 30d, 90d or all" },
HelpFlag { short: "", long: "--costs", value: "", desc: "Show token usage and estimated spend per agent (issue #49)" },
],
subcommands: &[],
parameters: &[
@@ -213,8 +275,131 @@ pub static HELP_SPECS: &[HelpSpec] = &[
],
io: None,
examples: &[
HelpExample { desc: "Global usage statistics.", code: "stats" },
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" },
HelpExample { desc: "Global statistics.", code: "stats" },
HelpExample { desc: "Last 30 days.", code: "stats --period 30d" },
HelpExample { desc: "Estimated spend.", code: "stats --period 30d --costs" },
],
},
HelpSpec {
name: "monitor",
category: "Commands",
usage: "monitor {flags}",
about: "Real-time TUI of the managed processes (CPU, RSS, uptime) with threshold alerts.",
search_terms: &["process", "cpu", "memory", "rss", "alert", "top", "surveillance"],
flags: &[
HelpFlag { short: "", long: "--interval", value: "SECONDS", desc: "Refresh interval (default 2)" },
HelpFlag { short: "", long: "--json", value: "", desc: "Print one JSON document per tick (for scripts)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Live TUI (q / Esc quits).", code: "monitor" },
HelpExample { desc: "Refresh every 5s.", code: "monitor --interval 5" },
HelpExample { desc: "JSON stream for scripts.", code: "monitor --json --interval 10" },
],
},
HelpSpec {
name: "web",
category: "Commands",
usage: "web {flags}",
about: "Local read-only web dashboard: overview, charts, sessions, events, projects (issue #54). Serves an embedded page and a JSON API with the same stable contracts as the CLI --json outputs; bound to 127.0.0.1 only.",
search_terms: &["web", "dashboard", "html", "http", "api", "graph", "chart", "navigateur", "browser", "port"],
flags: &[
HelpFlag { short: "", long: "--port", value: "PORT", desc: "Port to listen on (default 7878)" },
HelpFlag { short: "", long: "--no-open", value: "", desc: "Do not open the browser automatically" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Open the dashboard in the browser.", code: "web" },
HelpExample { desc: "Serve on another port, no browser.", code: "web --port 9090 --no-open" },
HelpExample { desc: "API routes: /api/health, /api/overview, /api/stats, /api/sessions, /api/events, /api/projects.", code: "curl http://127.0.0.1:7878/api/health" },
],
},
HelpSpec {
name: "history",
category: "Commands",
usage: "history {flags} [range]",
about: "Structured history of every REPL command, with search, re-run and playbook export (issue #51).",
search_terms: &["history", "search", "rerun", "playbook", "save", "replay"],
flags: &[
HelpFlag { short: "", long: "--kind", value: "KIND", desc: "Only commands of this kind (am or shell)" },
HelpFlag { short: "", long: "--cwd", value: "DIR", desc: "Only commands run in this directory" },
HelpFlag { short: "", long: "--search", value: "TEXT", desc: "Full-text search in the command line" },
HelpFlag { short: "", long: "--failed", value: "", desc: "Only failed commands" },
HelpFlag { short: "", long: "--session", value: "ID", desc: "Only commands of this session" },
HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum entries (default 100)" },
HelpFlag { short: "", long: "--rerun", value: "N", desc: "Re-run the Nth most recent entry" },
HelpFlag { short: "", long: "--save", value: "FILE", desc: "Export the range as a playbook YAML (issue #51)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "range", typ: "from..to", desc: "1-based inclusive range, newest first (e.g. 12..25)" },
],
io: None,
examples: &[
HelpExample { desc: "List the last commands.", code: "history" },
HelpExample { desc: "Re-run the 5th most recent entry.", code: "history --rerun 5" },
HelpExample { desc: "Save entries 12..25 as a playbook.", code: "history 12..25 --save deploy.yaml" },
],
},
HelpSpec {
name: "playbook",
category: "Commands",
usage: "playbook <file> {flags}",
about: "Replay a saved playbook step by step with confirmation; stops with a non-zero exit code on failure (issue #51).",
search_terms: &["playbook", "replay", "rejouer", "steps", "automation"],
flags: &[
HelpFlag { short: "", long: "--var", value: "NAME=VALUE", desc: "Variable value, repeatable ({{name}} in the steps)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "file", typ: "path", desc: "Playbook file (bare name resolves under state_dir/playbooks/)" },
],
io: None,
examples: &[
HelpExample { desc: "Replay with confirmation for each step.", code: "playbook deploy.yaml" },
HelpExample { desc: "Replay without asking, with a variable.", code: "playbook deploy.yaml --yes --var agent=claude-code" },
],
},
HelpSpec {
name: "lab",
category: "Commands",
usage: "lab --agents a,b --task <file> {flags}",
about: "Benchmark: run the same task on several agents and compare duration, exit code, output and estimated cost (issue #62).",
search_terms: &["benchmark", "compare", "comparer", "bench", "perf"],
flags: &[
HelpFlag { short: "", long: "--agents", value: "LIST", desc: "Comma-separated agent names (or aliases) to benchmark (required)" },
HelpFlag { short: "", long: "--task", value: "FILE", desc: "Task file (bare name resolves under state_dir/lab/, .yaml appended)" },
HelpFlag { short: "", long: "--parallel", value: "", desc: "Run every agent concurrently instead of one after another" },
HelpFlag { short: "", long: "--timeout", value: "SECS", desc: "Override the task timeout in seconds" },
HelpFlag { short: "", long: "--list", value: "", desc: "List the task files of the lab directory" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Run the task sequentially on two agents.", code: "lab --agents claude-code,aider --task hello.yaml" },
HelpExample { desc: "Machine-readable comparative report.", code: "lab --agents claude-code,codex --task bench.yaml --json --parallel" },
],
},
HelpSpec {
name: "plugins",
category: "Commands",
usage: "plugins {flags}",
about: "List the event plugins of the state directory and test one against a synthetic event (issue #75).",
search_terms: &["plugin", "extension", "hooks", "notify", "webhook"],
flags: &[
HelpFlag { short: "", long: "--test", value: "NAME", desc: "Run one plugin on a synthetic event and print its response (CI hook)" },
],
subcommands: &[],
parameters: &[],
io: Some(("JSON (stdin)", "JSON object (stdout)")),
examples: &[
HelpExample { desc: "List the installed plugins.", code: "plugins" },
HelpExample { desc: "Exercise the contract of one plugin.", code: "plugins --test notify" },
],
},
HelpSpec {
@@ -367,6 +552,96 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Supervise with auto-restart.", code: "watch claude-code --restart --notify" },
],
},
HelpSpec {
name: "models",
category: "Commands",
usage: "models {flags}",
about: "List the models of the local runtimes (ollama, llama.cpp, LM Studio) and prune unused ones.",
search_terms: &["ollama", "llama", "lm studio", "model", "prune", "gguf"],
flags: &[
HelpFlag { short: "", long: "--prune", value: "", desc: "Purge unused models instead of listing them" },
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Only list what would be pruned, delete nothing" },
HelpFlag { short: "", long: "--days", value: "DAYS", desc: "Consider a model unused after N days (default: 30)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "List the local models.", code: "models" },
HelpExample { desc: "Preview the prune candidates.", code: "models --prune --dry-run" },
HelpExample { desc: "Prune models unused for 60 days.", code: "models --prune --days 60" },
],
},
HelpSpec {
name: "catalog",
category: "Commands",
usage: "catalog {flags} <update|add|list> [url]",
about: "Manage remote agent catalogs: refresh the official one or register external catalogs by URL.",
search_terms: &["remote", "url", "team", "share", "include"],
flags: &[],
subcommands: &[
("update", "Fetch the official catalog, show the diff and register it"),
("add", "Register an external catalog by URL"),
("list", "List the registered remote catalogs"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Refresh the official catalog.", code: "catalog update" },
HelpExample { desc: "Add a team catalog.", code: "catalog add https://git.dracodev.net/team/agents.yaml" },
],
},
HelpSpec {
name: "suggest",
category: "Commands",
usage: "suggest <query>",
about: "Recommend agents for a natural-language request, ranked by relevance and real usage.",
search_terms: &["recommend", "recommendation", "find an agent", "what agent"],
flags: &[],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Find an agent for Python.", code: "suggest un agent pour du Python" },
HelpExample { desc: "Machine-readable output.", code: "suggest frontend framework --json" },
],
},
HelpSpec {
name: "audit",
category: "Commands",
usage: "audit {flags}",
about: "Who changed what, when: config checksums vs the event journal, manual edits flagged.",
search_terms: &["security", "trace", "checksum", "who changed", "history config"],
flags: &[
HelpFlag { short: "", long: "--json", value: "", desc: "Stable machine-readable report" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Run the config audit.", code: "audit" },
HelpExample { desc: "Machine-readable report.", code: "audit --json" },
],
},
HelpSpec {
name: "update",
category: "Commands",
usage: "update {flags} <agent|--all>",
about: "Update installed agents; every update is backed up first and can be undone.",
search_terms: &["upgrade", "rollback", "restore", "backup"],
flags: &[
HelpFlag { short: "", long: "--all", value: "", desc: "Update every managed agent (one backup for the batch)" },
HelpFlag { short: "", long: "--rollback", value: "[POINT]", desc: "Restore a pre-update backup ('list' shows them, default: latest)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Update one agent (backup taken first).", code: "update claude-code" },
HelpExample { desc: "List the available backups.", code: "update --rollback list" },
HelpExample { desc: "Undo the last update.", code: "update --rollback" },
],
},
HelpSpec {
name: "alias",
category: "Commands",
@@ -467,17 +742,19 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "init",
category: "Commands",
usage: "init {flags}",
about: "Generate a local agent-manager.yaml for the current directory, based on the detected stack.",
search_terms: &["project", "setup", "scaffold"],
about: "Generate a local agent-manager.yaml — stack detection or an embedded template.",
search_terms: &["project", "setup", "scaffold", "template"],
flags: &[
HelpFlag { short: "", long: "--force", value: "", desc: "Overwrite an existing agent-manager.yaml" },
HelpFlag { short: "", long: "--template", value: "NAME", desc: "web | python | rust | cli ('list' shows them)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Generate a project config.", code: "init" },
HelpExample { desc: "Regenerate over an existing one.", code: "init --force" },
HelpExample { desc: "Generate a project config (stack detection).", code: "init" },
HelpExample { desc: "Generate a web-stack config.", code: "init --template web" },
HelpExample { desc: "List the available templates.", code: "init --template list" },
],
},
HelpSpec {
@@ -520,31 +797,12 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
],
},
HelpSpec {
name: "update",
category: "Commands",
usage: "update {flags} [agent]",
about: "Update an installed agent to the latest available version.",
search_terms: &["upgrade"],
flags: &[
HelpFlag { short: "", long: "--all", value: "", desc: "Update every installed managed agent" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (required unless --all)" },
],
io: None,
examples: &[
HelpExample { desc: "Update one agent.", code: "update claude-code" },
HelpExample { desc: "Update every managed agent.", code: "update --all" },
],
},
HelpSpec {
name: "start",
category: "Commands",
usage: "start {flags} <agent>",
about: "Start an agent (foreground by default, or detached with --background). --profile applies an environment profile (env + args).",
search_terms: &["launch", "begin"],
search_terms: &["launch", "begin", "group", "parallel", "healthcheck"],
flags: START_FLAGS,
subcommands: &[],
parameters: &[
@@ -554,7 +812,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
examples: &[
HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" },
HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" },
HelpExample { desc: "Start every agent of a group.", code: "start group:dev --background" },
HelpExample { desc: "Start every agent of a group, waiting each health check.", code: "start group:dev --background" },
HelpExample { desc: "Start a group without waiting.", code: "start group:dev --parallel" },
HelpExample { desc: "Start with an environment profile.", code: "start claude-code --profile prod" },
],
},
@@ -608,8 +867,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[
category: "Commands",
usage: "run {flags} <agent> ...(args)",
about: "Run the agent command directly with the given arguments (no process management).",
search_terms: &["exec", "pass-through"],
flags: &[],
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
flags: &[
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Local model to use (issue #71)" },
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
@@ -625,17 +887,58 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "doctor",
category: "Commands",
usage: "doctor {flags}",
about: "Check the environment: tools, config validity, paths, permissions.",
search_terms: &["check", "environment", "repair"],
about: "Check the environment; --watch re-runs periodically and alerts on failure.",
search_terms: &["health", "watch", "check", "environment", "repair"],
flags: &[
HelpFlag { short: "", long: "--fix", value: "", desc: "Attempt to repair problems (create directories, fix the state file)" },
HelpFlag { short: "", long: "--fix", value: "", desc: "Repair what can be repaired automatically" },
HelpFlag { short: "", long: "--watch", value: "SECONDS", desc: "Re-run the checks every N seconds; alert + event on failure, recover silently" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Check the environment.", code: "doctor" },
HelpExample { desc: "Check and repair.", code: "doctor --fix" },
HelpExample { desc: "One-shot environment report.", code: "doctor" },
HelpExample { desc: "Watch every 30s, alerting on failure.", code: "doctor --watch 30" },
HelpExample { desc: "Repair what can be fixed.", code: "doctor --fix" },
],
},
HelpSpec {
name: "service",
category: "Commands",
usage: "service install|uninstall|status <agent> {flags}",
about: "Register an agent as a system service (systemd / launchd / Task Scheduler).",
search_terms: &["systemd", "autostart", "launchd", "task scheduler", "daemon"],
flags: &[
HelpFlag { short: "", long: "--autostart", value: "", desc: "Start the service at boot/logon" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name" },
],
io: None,
examples: &[
HelpExample { desc: "Install with autostart.", code: "service install claude-code --autostart" },
HelpExample { desc: "Remove the service.", code: "service uninstall claude-code" },
HelpExample { desc: "Check the service.", code: "service status claude-code" },
],
},
HelpSpec {
name: "schedule",
category: "Commands",
usage: "schedule add|list|remove <cmd...> {flags}",
about: "Plan am commands (daily) and run them with health reporting.",
search_terms: &["cron", "daily", "task scheduler", "scheduled", "planning"],
flags: &[
HelpFlag { short: "", long: "--at", value: "HH:MM", desc: "Time of day (default 08:00)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Daily update of every agent at 06:30.", code: "schedule add update --all --at 06:30" },
HelpExample { desc: "List the schedules.", code: "schedule list" },
HelpExample { desc: "Remove a schedule.", code: "schedule remove <id>" },
HelpExample { desc: "Run a schedule now (used by the OS task).", code: "schedule run <id>" },
],
},
HelpSpec {
@@ -885,8 +1188,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 +1199,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 +1257,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",
+13
View File
@@ -198,6 +198,19 @@ pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
out
}
/// Read the entries of one session file (oldest first). Missing or corrupt
/// files yield an empty list — an export never fails on history.
pub fn read_session(app: &App, session: &str) -> Vec<HistoryEntry> {
let path = dir(app).join(format!("{session}.jsonl"));
let Ok(text) = std::fs::read_to_string(&path) else {
return Vec::new();
};
text.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
.collect()
}
/// Parse the session id from a history file name ("20260815_143926.jsonl").
pub fn session_of_file(path: &Path) -> Option<String> {
path.file_name()
+550
View File
@@ -0,0 +1,550 @@
//! Internationalization (issue #74): locale selection and message lookup.
//!
//! The catalog uses the gettext convention: the **French message is the key**.
//! `tr()` returns its argument unchanged in French mode (zero behavior change
//! for the default locale), and the English translation when one exists in
//! the catalog. A message without an entry falls back to the French text, so
//! the CLI never shows raw keys.
//!
//! Locale resolution order (first match wins):
//! 1. the `--lang` global flag (`am --lang en <cmd>`),
//! 2. the `AM_LANG` environment variable,
//! 3. the `LANG` / `LC_ALL` / `LC_MESSAGES` environment variables,
//! 4. the default: French.
//!
//! Locale values are parsed leniently (`en`, `EN`, `en-US`, `en_US` … all
//! select English); anything unrecognized falls back to the default. Adding
//! another language only requires a new `Lang` variant and one more column in
//! the catalog entries.
//!
//! The `--json` contracts are untouched: only display strings go through the
//! catalog, never serialized field names or event kinds.
use std::sync::atomic::{AtomicU8, Ordering};
/// The two supported output languages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Fr,
En,
}
impl Lang {
pub fn code(self) -> &'static str {
match self {
Lang::Fr => "fr",
Lang::En => "en",
}
}
pub fn is_en(self) -> bool {
matches!(self, Lang::En)
}
/// Human-readable label, in the language itself.
pub fn label(self) -> &'static str {
match self {
Lang::Fr => "français",
Lang::En => "english",
}
}
}
// ValueEnum so clap accepts `--lang fr|en` and rejects unknown values.
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum LangArg {
Fr,
En,
}
impl From<LangArg> for Lang {
fn from(v: LangArg) -> Self {
match v {
LangArg::Fr => Lang::Fr,
LangArg::En => Lang::En,
}
}
}
/// Active locale, as an atomic (the REPL can switch it at runtime).
static ACTIVE: AtomicU8 = AtomicU8::new(0); // 0 = Fr, 1 = En
/// Current locale.
pub fn active() -> Lang {
match ACTIVE.load(Ordering::Relaxed) {
1 => Lang::En,
_ => Lang::Fr,
}
}
/// Set the active locale (startup from `--lang`/env, or the REPL `lang`
/// command).
pub fn set_lang(lang: Lang) {
ACTIVE.store(if lang.is_en() { 1 } else { 0 }, Ordering::Relaxed);
}
/// Resolve the locale: explicit flag, then `AM_LANG`, then `LANG`/`LC_ALL`/
/// `LC_MESSAGES`, then the French default.
pub fn resolve(flag: Option<LangArg>) -> Lang {
if let Some(l) = flag {
return l.into();
}
if let Ok(v) = std::env::var("AM_LANG") {
if let Some(l) = parse(&v) {
return l;
}
}
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(v) = std::env::var(var) {
if let Some(l) = parse(&v) {
return l;
}
}
}
Lang::Fr
}
/// Lenient parsing: "en", "EN", "en-US", "en_US.UTF-8", "fr-FR" …
fn parse(value: &str) -> Option<Lang> {
let v = value.trim().to_ascii_lowercase();
if v.starts_with("en") {
return Some(Lang::En);
}
if v.starts_with("fr") {
return Some(Lang::Fr);
}
None
}
// ---------------------------------------------------------------------------
// Catalog
// ---------------------------------------------------------------------------
//
// One entry per message: `(French message, English translation)`. The French
// text is the stable key (gettext convention): it is fixed by the call site
// and doubles as the fallback. Placeholders (`{}`, `{name}`) must appear in
// the same order in both languages — a test enforces it.
pub const CATALOG: &[(&str, &str)] = &[
// ---- messages généraux -------------------------------------------------
("aucun agent installé ou en cours d'exécution", "no installed or running agents"),
("aucun agent installé", "no installed agents"),
("aucun résultat", "no results"),
("aucun", "none"),
("inconnu", "unknown"),
("disponible", "available"),
("activé", "enabled"),
("désactivé", "disabled"),
("oui", "yes"),
("non", "no"),
("terminé", "finished"),
("échec", "failed"),
("interrompu", "interrupted"),
("en cours", "running"),
("installé", "installed"),
("non installé", "not installed"),
("externe", "external"),
("non installable", "not installable"),
("erreur", "error"),
("avertissement", "warning"),
("information", "info"),
("confirmer", "confirm"),
("annuler", "cancel"),
("version", "version"),
("chemin", "path"),
("projet", "project"),
("durée", "duration"),
("statut", "status"),
("session", "session"),
("date", "date"),
("début", "start"),
("fin", "end"),
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"),
("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"),
("vouliez-vous dire", "did you mean"),
("aucune détectée", "none detected"),
("aucune commande enregistrée", "no recorded commands"),
("lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici", "start the am shell and type a few commands: your habits will appear here"),
("am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'", "am help <command> for details · am tip --random for a single tip · inside the am shell, just type 'tip'"),
("Le saviez-vous ?", "Did you know?"),
("Vos commandes les plus utilisées", "Your most used commands"),
("Lancer & piloter", "Run & control"),
("Installer & maintenir", "Install & maintain"),
("Observer", "Observe"),
("Personnaliser", "Customize"),
("Boîte à outils", "Toolbox"),
("démarre un agent (premier plan par défaut)", "starts an agent (foreground by default)"),
("arrête un agent en arrière-plan (SIGTERM puis SIGKILL)", "stops a background agent (SIGTERM then SIGKILL)"),
("redémarre avec les mêmes options (agent, groupe, profil…)", "restarts with the same options (agent, group, profile…)"),
("supervise l'agent et le relance s'il meurt", "supervises the agent and restarts it if it dies"),
("exécute la commande de l'agent directement (sans gestion de processus)", "runs the agent command directly (no process management)"),
("agents installés (managed + external détectés sur le PATH)", "installed agents (managed + external detected on PATH)"),
("cherche dans le catalogue (nom, description, catégorie, tags)", "searches the catalog (name, description, category, tags)"),
("recommande un agent pour une demande en langage naturel (tags + usage réel)", "recommends an agent for a natural-language request (tags + real usage)"),
("inventaire des modèles locaux (ollama, llama.cpp, LM Studio)", "inventory of local models (ollama, llama.cpp, LM Studio)"),
("rafraîchit le catalogue officiel (diff + confirmation)", "refreshes the official catalog (diff + confirmation)"),
("qui a modifié quoi, quand (checksums config + journal d'événements)", "who changed what, when (config checksums + event journal)"),
("agent en service système (systemd / launchd / tâche Windows) avec démarrage auto", "registers the agent as a system service (systemd / launchd / Windows task) with autostart"),
("planifie une commande am quotidienne (ex: update --all le matin)", "schedules a daily am command (e.g. update --all in the morning)"),
("vérifications de l'environnement en continu, alerte dès qu'un problème apparaît", "continuous environment checks, alerts as soon as a problem appears"),
("démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)", "starts a whole group (ordered + health wait by default, --parallel without waiting)"),
("annule la dernière mise à jour (backup automatique avant chaque update)", "rolls back the last update (automatic backup before every update)"),
("génère une config projet pré-remplie (web, python, rust, cli)", "generates a pre-filled project config (web, python, rust, cli)"),
("exporte une session : métadonnées + commandes + extrait de log (JSON)", "exports a session: metadata + commands + log excerpt (JSON)"),
("installe l'agent et ses dépendances (Node, Python, Rust…)", "installs the agent and its dependencies (Node, Python, Rust…)"),
("met à jour un agent vers la dernière version disponible", "updates an agent to the latest available version"),
("supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)", "removes the files and the state entry (completion refreshes)"),
("état de tous les agents (running / installed / external)", "state of every agent (running / installed / external)"),
("lit la fin du journal de l'agent", "reads the end of the agent log"),
("TUI en direct : activité, stats, sessions, projets", "live TUI: activity, stats, sessions, projects"),
("historique des sessions · statistiques · agents les plus utilisés", "session history · statistics · most used agents"),
("TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils", "real-time TUI of managed processes (CPU, memory, uptime) with threshold alerts"),
("pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", "pushes the state (state.json, journal, history) into the configured git repo (sync_repo)"),
("export/import d'un bundle de transfert machine A → B (config + état + historique + backups)", "export/import of a machine-transfer bundle A → B (config + state + history + backups)"),
("exporte une plage d'historique en playbook YAML (état_dir/playbooks/)", "exports a history range as a YAML playbook (state_dir/playbooks/)"),
("benchmark : même tâche sur plusieurs agents (durée, exit, coût)", "benchmark: the same task on several agents (duration, exit, cost)"),
("scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)", "event extension scripts (JSON contract stdin/stdout, timeout)"),
("une vue chronologique de toute l'activité", "a chronological view of all activity"),
("crée un raccourci (ex. cc → claude-code)", "creates a shortcut (e.g. cc → claude-code)"),
("profils d'environnement réutilisables (env + args)", "reusable environment profiles (env + args)"),
("configuration effective (catalogue fusionné, alias, groupes…)", "effective configuration (merged catalog, aliases, groups…)"),
("change la palette du shell, des tableaux et de cette page", "changes the palette of the shell, the tables and this page"),
("stocke un secret dans le keyring de l'OS, jamais en clair", "stores a secret in the OS keyring, never in plaintext"),
("annotez vos agents : étoile, note libre, tags personnels", "annotate your agents: star, free note, personal tags"),
("diagnostique l'environnement et répare les problèmes courants", "diagnoses the environment and fixes common problems"),
("sauvegarde config + état, puis restauration", "backs up config + state, then restores"),
("complétion dynamique : agents installés, alias et groupes au Tab", "dynamic completion: installed agents, aliases and groups on Tab"),
("page de manuel pour une commande", "manual page for a command"),
("cherche une commande dans toute l'aide", "searches every help page for a command"),
("met à jour agent-manager lui-même", "updates agent-manager itself"),
("détaché en arrière-plan, sortie dans le log", "detached in the background, output goes to the log"),
("variable d'environnement (répétable)", "environment variable (repeatable)"),
("profil d'environnement (env + args)", "environment profile (env + args)"),
("kill immédiat, sans délai de grâce", "immediate kill, no grace period"),
("délai de grâce avant le kill (défaut : 5 s)", "grace period before the kill (default: 5 s)"),
("relance automatique avec backoff", "automatic restart with backoff"),
("notification bureau à chaque relance", "desktop notification on every restart"),
("catalogue complet, y compris non installés", "the whole catalog, including uninstalled agents"),
("uniquement les agents actifs", "only active agents"),
("name, version ou status", "name, version or status"),
("filtre par catégorie", "filter by category"),
("sortie machine-readable", "machine-readable output"),
("purge les modèles inutilisés", "prunes unused models"),
("simulation sans rien supprimer", "simulation, deletes nothing"),
("seuil d'inutilisation (défaut : 30)", "unused threshold (default: 30)"),
("ajoute un catalogue d'équipe par URL", "adds a team catalog by URL"),
("rapport machine-readable", "machine-readable report"),
("état du service", "service status"),
("retire le service", "removes the service"),
("liste les planifications", "lists the schedules"),
("supprime une planification", "removes a schedule"),
("répare automatiquement ce qui peut l'être", "automatically repairs what can be repaired"),
("exécute l'agent dans son profil conteneur (docker/podman)", "runs the agent inside its container profile (docker/podman)"),
("liste les points de restauration", "lists the restore points"),
("restaure un point précis", "restores a specific point"),
("liste les templates disponibles", "lists the available templates"),
("destination (défaut ./am-session-<id>.json)", "destination (default ./am-session-<id>.json)"),
("purge les sessions terminées au-delà de N jours", "purges sessions finished more than N days ago"),
("choisit la méthode (index ou type:npm, binary…)", "selects the method (index or type:npm, binary…)"),
("réinstalle même si déjà présent", "reinstalls even if already present"),
("tous les agents installés d'un coup", "every installed agent at once"),
("supprime aussi les logs et la définition de la config", "also removes the logs and the config definition"),
("suit le log en direct", "follows the log live"),
("nombre de lignes (défaut : 20)", "number of lines (default: 20)"),
("7d, 30d, 90d ou all", "7d, 30d, 90d or all"),
("tokens + coût estimé par agent (issue #49)", "tokens + estimated cost per agent (issue #49)"),
("rafraîchissement (défaut 2s)", "refresh interval (default 2s)"),
("flux JSON pour les scripts", "JSON stream for scripts"),
("message de commit personnalisé", "custom commit message"),
("pousse automatiquement à la fermeture du REPL", "pushes automatically when the REPL closes"),
("crée agent-manager-migrate.amx", "creates agent-manager-migrate.amx"),
("chemin du bundle", "bundle path"),
("fichier playbook", "playbook file"),
("rejoue pas à pas avec confirmation", "replays step by step with confirmation"),
("exécution concurrente", "concurrent execution"),
("rapport comparatif stable (scripts/CI)", "stable comparison report (scripts/CI)"),
("exécute un plugin sur un événement de test (CI)", "runs a plugin against a test event (CI)"),
("activité à partir d'une date", "activity from a given date"),
("fichier de destination (défaut : am-export.json)", "destination file (default: am-export.json)"),
("vérifie seulement si une version existe", "only checks whether a version exists"),
("exécution séquentielle", "sequential execution"),
("kill de l'arbre de processus au timeout", "kills the process tree on timeout"),
("Tab complète vos agents", "Tab completes your 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.", "in the am shell, type 'start ' then Tab: installed agents, aliases and groups show up — and the list refreshes after every install/uninstall."),
("!! relance la dernière commande", "!! re-runs the last command"),
("l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.", "the shell history accepts !!, !N and !prefix to re-run a command without retyping it."),
("Le shell am double le shell système", "The am shell doubles as the system shell"),
("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.", "anything that is not an am command is run by your shell (pwsh, cmd, bash…): no need to leave to launch a tool."),
("--dry-run simule sans rien changer", "--dry-run simulates without changing anything"),
("testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.", "test 'install', 'update' or 'uninstall' safely: am shows what it would do, without touching the disk."),
("Les groupes lancent plusieurs agents", "Groups launch several agents"),
("'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.", "'start group:dev' launches the whole group at once; create your own in the config."),
("Cherchez dans toute l'aide", "Search all the help"),
("'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.", "'am help --find <word>' scans the descriptions, usages and examples of every command."),
("--json pour les scripts", "--json for scripts"),
("quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.", "almost every command accepts --json and outputs structured data, perfect for your scripts."),
("Les alias raccourcissent tout", "Aliases shorten everything"),
("'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.", "'alias add cc claude-code' then 'start cc': a short name for every agent."),
("export/import sauvegardent votre monde", "export/import back up your world"),
("config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.", "config, state, annotations and profiles go into a JSON file, restorable with 'import'."),
("Les secrets vivent dans le keyring", "Secrets live in the keyring"),
("'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.", "'secret set API_KEY --agent pi' stores the value in the OS keychain, never in plaintext in the config."),
("Le thème suit votre humeur", "The theme follows your mood"),
("'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.", "'theme ocean', 'theme dracula'… the palette of the am shell, the tables and this page changes instantly."),
("doctor répare", "doctor repairs"),
("'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).", "'am doctor --fix' checks the environment and fixes common problems (missing folders, corrupted state…)."),
// ---- web (issue #54) ---------------------------------------------------
("am web — {} (Ctrl-C pour arrêter)", "am web — {} (Ctrl-C to stop)"),
("ouverture du navigateur…", "opening the browser…"),
("Vue d'ensemble", "Overview"),
("Statistiques", "Statistics"),
("Événements", "Events"),
("Projets", "Projects"),
("Agents installés", "Installed agents"),
("En cours", "Running"),
("Sessions (7 j)", "Sessions (7d)"),
("Événements (24 h)", "Events (24h)"),
("Top agents — sessions (7 j)", "Top agents — sessions (7d)"),
("Activité — sessions par jour (14 j)", "Activity — sessions per day (14d)"),
("Activité récente", "Recent activity"),
("Sessions en cours", "Running sessions"),
("Statistiques par agent — période 30 j", "Per-agent statistics — last 30 days"),
("Session", "Session"),
("Agent", "Agent"),
("Projet", "Project"),
("Statut", "Status"),
("Durée", "Duration"),
("Heure", "Time"),
("Type", "Type"),
("Détail", "Details"),
("Dernière activité", "Last activity"),
("Journal d'événements — aujourd'hui", "Event journal — today"),
("Dernière mise à jour", "Last updated"),
("Filtrer par agent, projet ou id…", "Filter by agent, project or id…"),
("Aucune session ne correspond au filtre", "No sessions match the filter"),
("Aucune session en cours", "No running sessions"),
("Aucun événement", "No events"),
("Aucune activité enregistrée", "No activity recorded yet"),
("Aucun projet suivi", "No tracked projects"),
("Actualiser", "Refresh"),
("serveur local", "local server"),
("Temps total", "Total time"),
("Projets suivis", "Tracked projects"),
("Terminée", "Finished"),
("Échec", "Failed"),
("Interrompue", "Interrupted"),
("chargement…", "loading…"),
("erreur de chargement", "load error"),
("Lancements", "Launches"),
("Durée moyenne", "Avg duration"),
("Échecs", "Failures"),
("Démarrée", "Started"),
("Fin", "End"),
];
/// Look up the English translation of a French message. Returns None when the
/// message is not in the catalog (the caller falls back to the French text).
pub fn lookup_en(fr: &str) -> Option<&'static str> {
CATALOG
.iter()
.find(|(k, _)| *k == fr)
.map(|(_, en)| *en)
}
/// Translate a message for the active locale. In French mode the input is
/// returned unchanged; in English mode the catalog translation is used, with
/// the French text as fallback when the message is not translated yet.
pub fn tr<'a>(key: &'a str) -> &'a str {
tr_in(active(), key)
}
/// Translate a message for an explicit locale.
pub fn tr_in<'a>(lang: Lang, key: &'a str) -> &'a str {
match lang {
Lang::Fr => key,
Lang::En => lookup_en(key).unwrap_or(key),
}
}
/// Format a localized message: `tr_fmt!("agent '{}' installé", name)`.
/// The English translation keeps the same placeholders (enforced by test).
/// Edition 2021 forbids non-literal format strings, so the formatting is
/// done by a small runtime `{}`-replacer; catalog entries therefore use
/// positional `{}` placeholders only (no `{name}`, `{:.1}`, …).
#[macro_export]
macro_rules! tr_fmt {
($key:literal $(, $arg:expr)*) => {
$crate::i18n::tr_fmt($key, &[$(&$arg as &dyn ::std::fmt::Display),*])
};
}
/// Format a message for the active locale, replacing every `{}` with the
/// Display rendering of the matching argument.
pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
match active() {
Lang::Fr => fmt_args(key, args),
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
}
}
/// Replace `{}` placeholders (positional) with the Display of each argument.
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
let mut i = 0;
while let Some(pos) = rest.find("{}") {
out.push_str(&rest[..pos]);
if let Some(arg) = args.get(i) {
out.push_str(&arg.to_string());
}
i += 1;
rest = &rest[pos + 2..];
}
out.push_str(rest);
out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Env-var tests mutate the process environment and would race when run
/// in parallel; serialize them.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn placeholders(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(i) = rest.find('{') {
let after = &rest[i..];
let close = after.find('}').map(|j| j + 1).unwrap_or(after.len());
out.push(after[..close].to_string());
rest = &after[close..];
}
out
}
#[test]
fn parses_locale_values() {
assert_eq!(parse("en"), Some(Lang::En));
assert_eq!(parse("EN"), Some(Lang::En));
assert_eq!(parse("en-US"), Some(Lang::En));
assert_eq!(parse("en_US.UTF-8"), Some(Lang::En));
assert_eq!(parse("fr"), Some(Lang::Fr));
assert_eq!(parse("fr-FR"), Some(Lang::Fr));
assert_eq!(parse("de"), None);
assert_eq!(parse(""), None);
assert_eq!(parse(" en "), Some(Lang::En));
}
#[test]
fn flag_wins_over_env() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("AM_LANG", "en");
std::env::set_var("LANG", "fr_FR");
let flag = Some(LangArg::En);
assert_eq!(resolve(flag), Lang::En);
let flag = Some(LangArg::Fr);
assert_eq!(resolve(flag), Lang::Fr);
}
#[test]
fn am_lang_env_beats_lang_env() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("AM_LANG", "en");
std::env::set_var("LANG", "fr_FR");
assert_eq!(resolve(None), Lang::En);
}
#[test]
fn lang_env_falls_back_to_default() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("AM_LANG");
std::env::set_var("LANG", "en_GB");
assert_eq!(resolve(None), Lang::En);
std::env::set_var("LANG", "fr_FR");
assert_eq!(resolve(None), Lang::Fr);
std::env::remove_var("LANG");
std::env::remove_var("LC_ALL");
std::env::remove_var("LC_MESSAGES");
assert_eq!(resolve(None), Lang::Fr);
}
#[test]
fn unknown_env_falls_back_to_default() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("AM_LANG");
std::env::set_var("LANG", "de_DE");
assert_eq!(resolve(None), Lang::Fr);
}
#[test]
fn tr_returns_input_in_french() {
set_lang(Lang::Fr);
assert_eq!(tr("aucun agent installé"), "aucun agent installé");
assert_eq!(tr("message non catalogué"), "message non catalogué");
}
#[test]
fn tr_translates_in_english_and_falls_back() {
set_lang(Lang::En);
assert_eq!(tr("aucun agent installé"), "no installed agents");
// Not in the catalog: the French text is the fallback, never a key.
assert_eq!(tr("message non catalogué"), "message non catalogué");
}
#[test]
fn tr_fmt_formats_both_locales() {
set_lang(Lang::Fr);
assert_eq!(crate::tr_fmt!("agent '{}' installé", "cc"), "agent 'cc' installé");
}
#[test]
fn fmt_args_replaces_positional_placeholders() {
assert_eq!(fmt_args("a {} b", &[&"x", &"y"]), "a x b");
assert_eq!(fmt_args("{}", &[&42]), "42");
assert_eq!(fmt_args("no placeholders", &[]), "no placeholders");
assert_eq!(fmt_args("{} {}", &[&1]), "1 ");
assert_eq!(fmt_args("", &[&1]), "");
}
#[test]
fn catalog_is_consistent() {
let mut seen = std::collections::HashSet::new();
for (fr, en) in CATALOG {
assert!(!fr.is_empty(), "empty french key");
assert!(!en.is_empty(), "empty english translation for {fr:?}");
assert!(seen.insert(*fr), "duplicate catalog key: {fr:?}");
// Placeholders must line up between the two languages.
assert_eq!(
placeholders(fr),
placeholders(en),
"placeholder mismatch for {fr:?}"
);
// Catalog entries only use positional {} placeholders: the
// runtime formatter (tr_fmt) cannot honour {name} or {:.1}.
assert!(
!fr.contains('{') || placeholders(fr).iter().all(|p| p == "{}"),
"{fr:?}: only '{{}}' placeholders are allowed in the catalog"
);
}
}
#[test]
fn every_placeholder_in_fr_is_present_in_en() {
for (fr, en) in CATALOG {
for ph in placeholders(fr) {
assert!(
en.contains(&ph),
"{fr:?}: placeholder {ph} missing in {en:?}"
);
}
}
}
}
+629
View File
@@ -0,0 +1,629 @@
//! am lab (issue #62): run the same task on several agents and compare
//! duration, exit code, output and estimated cost. Tasks are plain YAML
//! files living in `<state_dir>/lab/` — versionable and replayable:
//! running the same file again reproduces the same benchmark.
//!
//! A task is the list of arguments passed to each agent's run command
//! (plus an optional working directory and timeout).
use crate::app::App;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
/// Default timeout for one task run (seconds).
pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 600;
/// Name of the tasks directory inside the state directory.
pub const DIR_NAME: &str = "lab";
/// A benchmark task: a versionable YAML file run on every agent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabTask {
/// Short name of the task (required).
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Arguments passed to every agent's run command.
#[serde(default)]
pub args: Vec<String>,
/// Working directory of the run (default: the current directory).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Timeout in seconds (default 600).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
}
/// Outcome of one agent on the task.
#[derive(Debug, Clone, Default, Serialize)]
pub struct LabRun {
pub agent: String,
/// ok | failed | timeout | error
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub duration_ms: u64,
/// Captured stdout of the run.
pub output: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub stderr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// The comparative report (also the --json contract: stable fields,
/// runs sorted by agent name).
#[derive(Debug, Clone, Serialize)]
pub struct LabReport {
pub task: String,
pub runs: Vec<LabRun>,
pub summary: LabSummary,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct LabSummary {
pub runs: usize,
pub ok: usize,
pub failed: usize,
pub timeout: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub fastest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cheapest: Option<String>,
}
/// The tasks directory of the state directory.
pub fn dir(app: &App) -> PathBuf {
app.events_dir().join(DIR_NAME)
}
/// Resolve a task path: a bare name goes into the lab directory (with
/// .yaml/.yml appended when the exact name does not exist), an explicit
/// path is honored as-is.
pub fn resolve_task(app: &App, dest: &Path) -> PathBuf {
let has_sep = dest.components().count() > 1
|| dest.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::CurDir
)
});
if has_sep || dest.is_absolute() {
return dest.to_path_buf();
}
let base = dir(app).join(dest);
if base.exists() {
return base;
}
for ext in ["yaml", "yml"] {
let candidate = dir(app).join(format!("{}.{}", dest.display(), ext));
if candidate.exists() {
return candidate;
}
}
base
}
/// Task files of the lab directory (yaml/yml), sorted by name.
pub fn list_tasks(app: &App) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(dir(app))
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("yaml" | "yml")
)
})
.collect()
})
.unwrap_or_default();
out.sort();
out
}
/// Load and validate a task file.
pub fn load_task(path: &Path) -> Result<LabTask> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let task: LabTask = serde_yaml::from_str(&text)
.with_context(|| format!("{}: invalid task YAML", path.display()))?;
if task.name.trim().is_empty() {
bail!("{}: 'name' is required", path.display());
}
Ok(task)
}
/// Split the --agents list ("a,b, c") into trimmed names.
pub fn split_agents(list: &str) -> Vec<String> {
list.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// A pre-resolved run context: owned data, safe to move across threads.
struct RunCtx {
agent: String,
program: String,
args: Vec<String>,
env: BTreeMap<String, String>,
cwd: Option<PathBuf>,
timeout: Duration,
cost_model: Option<String>,
sheet: BTreeMap<String, crate::config::CostModel>,
}
/// Run the task on every agent and build the comparative report.
/// Sequential by default; `parallel` spawns one thread per agent.
pub fn run_benchmark(
app: &App,
agents: &[String],
task: &LabTask,
parallel: bool,
timeout_override: Option<u64>,
) -> Result<LabReport> {
let timeout = Duration::from_secs(
timeout_override.unwrap_or(task.timeout.unwrap_or(DEFAULT_TASK_TIMEOUT_SECS)),
);
let task_cwd = task.cwd.as_deref().map(crate::config::expand_path);
if let Some(cwd) = &task_cwd {
if !cwd.is_dir() {
bail!("task cwd does not exist: {}", cwd.display());
}
}
let sheet = app
.config
.settings
.cost_models
.clone()
.unwrap_or_else(crate::config::default_cost_models);
let mut ctxs = Vec::new();
for name in agents {
let agent = crate::commands::require_agent(app, name)?;
let exec = crate::commands::resolve_exec(app, agent, &task.args, &BTreeMap::new())?;
ctxs.push(RunCtx {
agent: agent.name.clone(),
program: exec.program,
args: exec.args,
env: exec.env,
cwd: task_cwd.clone(),
timeout,
cost_model: agent.cost_model.clone(),
sheet: sheet.clone(),
});
}
let mut runs: Vec<LabRun> = if parallel {
let handles: Vec<_> = ctxs
.into_iter()
.map(|ctx| std::thread::spawn(move || run_one(&ctx)))
.collect();
handles
.into_iter()
.map(|h| {
h.join().unwrap_or_else(|_| LabRun {
agent: "?".to_string(),
status: "error".to_string(),
error: Some("runner thread panicked".to_string()),
..Default::default()
})
})
.collect()
} else {
ctxs.iter().map(run_one).collect()
};
// Stable order for --json: always sorted by agent name.
runs.sort_by(|a, b| a.agent.cmp(&b.agent));
// Journal one Lab event per run (axe 1: everything is traceable).
for run in &runs {
let mut ev = crate::events::Event::now(crate::events::EventKind::Lab)
.with_agent(run.agent.clone())
.with_reason(run.status.clone())
.with_args(task.args.clone())
.with_duration(run.duration_ms / 1000);
if let Some(code) = run.exit_code {
ev = ev.with_exit_code(code);
}
if let Some(cost) = run.cost_usd {
ev = ev
.with_usage(run.tokens_in.unwrap_or(0), run.tokens_out.unwrap_or(0))
.with_cost(cost);
}
app.emit(&ev);
}
Ok(LabReport {
task: task.name.clone(),
summary: summarize(&runs),
runs,
})
}
/// Run one agent on the task with a timeout; captures stdout/stderr.
fn run_one(ctx: &RunCtx) -> LabRun {
let started = Instant::now();
let mut cmd = Command::new(&ctx.program);
cmd.args(&ctx.args)
.envs(&ctx.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(cwd) = &ctx.cwd {
cmd.current_dir(cwd);
}
set_process_group(&mut cmd);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return LabRun {
agent: ctx.agent.clone(),
status: "error".to_string(),
error: Some(format!("cannot spawn: {e:#}")),
duration_ms: started.elapsed().as_millis() as u64,
..Default::default()
}
}
};
let stdout = read_pipe(child.stdout.take());
let stderr = read_pipe(child.stderr.take());
let deadline = started + ctx.timeout;
let outcome = loop {
match child.wait_timeout(Duration::from_millis(200)) {
Ok(Some(status)) => break Ok(status),
Ok(None) => {
if Instant::now() >= deadline {
crate::process::kill_tree(child.id());
let _ = child.wait();
break Err("timeout".to_string());
}
}
Err(e) => {
crate::process::kill_tree(child.id());
let _ = child.wait();
break Err(format!("cannot wait: {e:#}"));
}
}
};
let out = stdout.join().unwrap_or_default();
let err = stderr.join().unwrap_or_default();
let (status, exit_code, error) = match outcome {
Ok(st) => {
let code = st.code().unwrap_or(-1);
(
if code == 0 { "ok" } else { "failed" }.to_string(),
Some(code),
None,
)
}
Err(e) => {
let kind = if e == "timeout" { "timeout" } else { "error" };
(kind.to_string(), None, Some(e))
}
};
let mut run = LabRun {
agent: ctx.agent.clone(),
status,
exit_code,
duration_ms: started.elapsed().as_millis() as u64,
output: out,
stderr: err,
error,
..Default::default()
};
// Estimated cost when the agent exposes its usage (axe 1, issue #49).
let cwd_str = ctx.cwd.as_deref().map(|p| p.display().to_string());
if let Some(usage) = crate::costs::collect_usage(&ctx.agent, cwd_str.as_deref()) {
let cost = crate::costs::estimate_cost(ctx.cost_model.as_deref(), usage, &ctx.sheet);
run.tokens_in = Some(usage.tokens_in);
run.tokens_out = Some(usage.tokens_out);
run.cost_usd = Some(cost);
}
run
}
/// Compute the summary rows of the report.
fn summarize(runs: &[LabRun]) -> LabSummary {
let mut s = LabSummary {
runs: runs.len(),
..Default::default()
};
let mut fastest: Option<(&str, u64)> = None;
let mut cheapest: Option<(&str, f64)> = None;
for r in runs {
match r.status.as_str() {
"ok" => s.ok += 1,
"failed" => s.failed += 1,
"timeout" => s.timeout += 1,
_ => {}
}
if r.status == "ok" && fastest.map(|(_, d)| r.duration_ms < d).unwrap_or(true) {
fastest = Some((&r.agent, r.duration_ms));
}
if let Some(cost) = r.cost_usd {
if cheapest.map(|(_, c)| cost < c).unwrap_or(true) {
cheapest = Some((&r.agent, cost));
}
}
}
s.fastest = fastest.map(|(a, _)| a.to_string());
s.cheapest = cheapest.map(|(a, _)| a.to_string());
s
}
/// Read a child pipe to the end on a dedicated thread (avoids pipe
/// deadlocks while the parent waits with a timeout).
fn read_pipe<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
if let Some(mut p) = pipe {
let _ = p.read_to_string(&mut buf);
}
buf
})
}
/// Put the child in its own process group so `kill_tree` can stop it with
/// its whole subtree on timeout.
fn set_process_group(cmd: &mut Command) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
fn test_app(tag: &str) -> App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = App::from_cli(cli).unwrap();
app.paths.state_file = dir.path().join(format!("state-{tag}.json"));
app.paths.log_dir = dir.path().join("logs");
app
}
/// An app whose catalog defines three shell-based fake agents:
/// bench-ok (echoes its first argument), bench-fail (exit 3),
/// bench-slow (infinite loop, for the timeout test).
fn bench_app(tag: &str) -> App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents:\n",
" - name: bench-ok\n",
" installable: false\n",
" run: sh\n",
" args: [\"-c\", \"echo out: $0\"]\n",
" - name: bench-fail\n",
" installable: false\n",
" run: sh\n",
" args: [\"-c\", \"echo boom >&2; exit 3\"]\n",
" - name: bench-slow\n",
" installable: false\n",
" run: sh\n",
" args: [\"-c\", \"while true; do :; done\"]\n",
),
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = App::from_cli(cli).unwrap();
app.paths.state_file = dir.path().join(format!("state-{tag}.json"));
app.paths.log_dir = dir.path().join("logs");
app
}
#[test]
fn split_agents_trims_and_filters() {
assert_eq!(split_agents("a,b"), vec!["a".to_string(), "b".to_string()]);
assert_eq!(split_agents(" a , b, c "), vec!["a", "b", "c"]);
assert_eq!(split_agents(" , "), Vec::<String>::new());
}
#[test]
fn task_loads_with_defaults() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("hello.yaml");
std::fs::write(
&f,
"name: hello\ndescription: test\nargs: [\"--prompt\", \"coucou\"]\n",
)
.unwrap();
let t = load_task(&f).unwrap();
assert_eq!(t.name, "hello");
assert_eq!(t.args, vec!["--prompt".to_string(), "coucou".to_string()]);
assert_eq!(t.timeout, None);
assert!(load_task(&dir.path().join("missing.yaml")).is_err());
std::fs::write(&f, "args: [x]\n").unwrap();
assert!(load_task(&f).is_err(), "name is required");
}
#[test]
fn bare_name_resolves_into_the_lab_dir() {
let app = test_app("dest");
let resolved = resolve_task(&app, Path::new("hello"));
assert_eq!(resolved, app.events_dir().join(DIR_NAME).join("hello"));
let abs = PathBuf::from("C:/tmp/hello.yaml");
assert_eq!(resolve_task(&app, &abs), abs);
}
#[test]
fn list_tasks_filters_yaml_files() {
let app = test_app("list");
let d = dir(&app);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("a.yaml"), "name: a\n").unwrap();
std::fs::write(d.join("b.yml"), "name: b\n").unwrap();
std::fs::write(d.join("notes.md"), "nope").unwrap();
let tasks = list_tasks(&app);
assert_eq!(tasks.len(), 2);
assert!(tasks[0].display().to_string().ends_with("a.yaml"));
assert!(tasks[1].display().to_string().ends_with("b.yml"));
}
#[test]
fn benchmark_runs_sequentially_and_reports() {
let app = bench_app("seq");
let task = LabTask {
name: "hello".into(),
description: None,
args: vec!["hello".to_string()],
cwd: None,
timeout: Some(30),
};
let agents = vec!["bench-ok".to_string(), "bench-fail".to_string()];
let report = run_benchmark(&app, &agents, &task, false, None).unwrap();
assert_eq!(report.task, "hello");
assert_eq!(report.runs.len(), 2);
let ok = report.runs.iter().find(|r| r.agent == "bench-ok").unwrap();
assert_eq!(ok.status, "ok");
assert_eq!(ok.exit_code, Some(0));
assert!(ok.output.contains("out: hello"), "got: {}", ok.output);
let fail = report.runs.iter().find(|r| r.agent == "bench-fail").unwrap();
assert_eq!(fail.status, "failed");
assert_eq!(fail.exit_code, Some(3));
assert!(fail.stderr.contains("boom"));
// Summary counts.
assert_eq!(report.summary.ok, 1);
assert_eq!(report.summary.failed, 1);
assert_eq!(report.summary.fastest.as_deref(), Some("bench-ok"));
}
#[test]
fn benchmark_is_replayable_identically() {
let app = bench_app("replay");
let task = LabTask {
name: "hello".into(),
description: None,
args: vec!["hello".to_string()],
cwd: None,
timeout: Some(30),
};
let agents = vec!["bench-ok".to_string()];
let r1 = run_benchmark(&app, &agents, &task, false, None).unwrap();
let r2 = run_benchmark(&app, &agents, &task, false, None).unwrap();
assert_eq!(r1.runs.len(), r2.runs.len());
assert_eq!(r1.runs[0].status, r2.runs[0].status);
assert_eq!(r1.runs[0].exit_code, r2.runs[0].exit_code);
assert_eq!(r1.runs[0].output, r2.runs[0].output);
// The --json contract is stable: same fields on both serializations.
let j1 = serde_json::to_string(&r1).unwrap();
let j2 = serde_json::to_string(&r2).unwrap();
assert!(j1.contains("\"task\":\"hello\""));
assert!(j1.contains("\"status\":\"ok\""));
assert!(j2.contains("\"status\":\"ok\""));
}
#[test]
fn timeout_kills_the_run() {
let app = bench_app("to");
let task = LabTask {
name: "slow".into(),
description: None,
args: vec![],
cwd: None,
timeout: Some(1),
};
let agents = vec!["bench-slow".to_string()];
let report = run_benchmark(&app, &agents, &task, false, None).unwrap();
assert_eq!(report.runs[0].status, "timeout");
assert_eq!(report.summary.timeout, 1);
}
#[test]
fn parallel_mode_runs_every_agent() {
let app = bench_app("par");
let task = LabTask {
name: "hello".into(),
description: None,
args: vec!["hello".to_string()],
cwd: None,
timeout: Some(30),
};
let agents = vec!["bench-ok".to_string(), "bench-fail".to_string()];
let report = run_benchmark(&app, &agents, &task, true, None).unwrap();
assert_eq!(report.runs.len(), 2);
// Runs are sorted by agent name: stable --json.
assert_eq!(report.runs[0].agent, "bench-fail");
assert_eq!(report.runs[1].agent, "bench-ok");
assert_eq!(report.summary.ok, 1);
}
#[test]
fn missing_cwd_is_an_error() {
let app = bench_app("cwd");
let task = LabTask {
name: "bad".into(),
description: None,
args: vec![],
cwd: Some("C:/does/not/exist-anywhere".to_string()),
timeout: None,
};
assert!(run_benchmark(&app, &["bench-ok".to_string()], &task, false, None).is_err());
}
#[test]
fn unknown_agent_is_an_error() {
let app = bench_app("unk");
let task = LabTask {
name: "t".into(),
description: None,
args: vec![],
cwd: None,
timeout: None,
};
assert!(run_benchmark(&app, &["ghost".to_string()], &task, false, None).is_err());
}
}
+41 -14
View File
@@ -12,7 +12,12 @@
//! * commands — one module per CLI command.
pub mod app;
pub mod automation;
pub mod backup;
pub mod catalog;
pub mod costs;
pub mod templates;
pub mod catalog_remote;
pub mod cli;
pub mod commands;
pub mod config;
@@ -25,7 +30,10 @@ pub mod events;
pub mod help;
pub mod history;
pub mod hooks;
pub mod i18n;
pub mod installers;
pub mod lab;
pub mod models;
pub mod nav;
pub mod output;
pub mod probe;
@@ -38,10 +46,14 @@ pub mod secrets;
pub mod sessions;
pub mod shell;
pub mod state;
pub mod sync;
pub mod playbook;
pub mod plugins;
pub mod tables;
pub mod theme;
pub mod toolchain;
pub mod version;
pub mod web;
use clap::Parser;
@@ -75,22 +87,37 @@ fn real_main() -> i32 {
return code;
}
let cli = cli::Cli::parse();
// Issue #74: resolve the output language before anything else so even
// config errors are localized; the REPL can switch it at runtime.
i18n::set_lang(i18n::resolve(cli.lang));
match app::App::from_cli(cli) {
Ok(app) => match commands::execute(&app) {
Ok(code) => code,
Err(err) => {
if app.json() {
let payload = serde_json::json!({
"error": format!("{:#}", err),
});
let _ = serde_json::to_writer_pretty(std::io::stdout(), &payload);
let _ = writeln!(std::io::stdout());
} else {
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
}
1
Ok(app) => {
// Issue #53: enforce the session retention at every startup
// (silent; the primary event journal is never touched).
let days = app
.config
.settings
.sessions_retention_days
.unwrap_or(sessions::DEFAULT_RETENTION_DAYS);
if let Err(e) = sessions::purge_expired(&app, days) {
app.log.verbose(&format!("session retention skipped: {e:#}"));
}
},
match commands::execute(&app) {
Ok(code) => code,
Err(err) => {
if app.json() {
let payload = serde_json::json!({
"error": format!("{:#}", err),
});
let _ = serde_json::to_writer_pretty(std::io::stdout(), &payload);
let _ = writeln!(std::io::stdout());
} else {
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
}
1
}
}
}
Err(err) => {
// Config could not even be loaded: fall back to plain stderr output.
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
+712
View File
@@ -0,0 +1,712 @@
//! Local model runtimes (axe 11): inventory of models available on
//! ollama, llama.cpp (llama-server) and LM Studio, with an on-disk cache
//! following the probe.rs pattern (keyed by runtime identity + timestamp),
//! and a prune helper for unused models.
//!
//! Rules:
//! * a missing runtime is reported, never fatal;
//! * the cache is bounded and short-lived (default TTL 30 s);
//! * parsing functions are pure and unit-tested with fixtures.
use crate::app::App;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use wait_timeout::ChildExt;
/// Default time-to-live of a cached runtime inventory, in seconds.
pub const CACHE_TTL_SECS: u64 = 30;
/// Default number of days before an unused model becomes a prune candidate.
pub const DEFAULT_PRUNE_DAYS: u64 = 30;
/// A model found on a local runtime.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RuntimeModel {
/// Runtime name: "ollama", "llama.cpp" or "lm-studio".
pub runtime: String,
/// Model identifier (e.g. "llama3.1:8b").
pub name: String,
/// Disk size in bytes when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
/// Quantization level when known (e.g. "Q4_K_M").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantization: Option<String>,
/// RFC 3339 timestamp of the last use/modification when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used: Option<String>,
/// Absolute path of the model file (llama.cpp) or empty.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
/// State of one runtime after detection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeInfo {
pub runtime: String,
/// Whether the runtime was found (binary on PATH / API reachable).
pub detected: bool,
/// Human-readable detail (version, endpoint, or why it was not found).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Full inventory: runtime states plus the models of the detected runtimes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModelsInventory {
pub runtimes: Vec<RuntimeInfo>,
pub models: Vec<RuntimeModel>,
}
impl ModelsInventory {
/// The models of one runtime (when detected).
pub fn models_of(&self, runtime: &str) -> Vec<&RuntimeModel> {
self.models
.iter()
.filter(|m| m.runtime == runtime)
.collect()
}
/// Look up a model by name across every runtime (name matched exactly,
/// or by short name when it matches a single model).
pub fn find(&self, name: &str) -> Option<&RuntimeModel> {
let exact: Vec<&RuntimeModel> = self
.models
.iter()
.filter(|m| m.name == name)
.collect();
match exact.len() {
0 => {
// Short name (e.g. "llama3"): unique suffix match wins.
let short: Vec<&RuntimeModel> = self
.models
.iter()
.filter(|m| m.name.split(':').next() == Some(name))
.collect();
(short.len() == 1).then(|| short[0])
}
1 => Some(exact[0]),
_ => {
// Ambiguous: prefer the "latest" tag, then the first.
exact
.iter()
.find(|m| m.name.ends_with(":latest"))
.copied()
.or(Some(exact[0]))
}
}
}
/// Is at least one runtime detected?
pub fn any_runtime(&self) -> bool {
self.runtimes.iter().any(|r| r.detected)
}
}
// ---------------------------------------------------------------------------
// Detection
// ---------------------------------------------------------------------------
/// Detect every local model runtime and their models, using the on-disk
/// cache. A steady-state run does no subprocess and no HTTP request.
pub fn detect(app: &App) -> ModelsInventory {
let cache_path = app.events_dir().join("models-cache.json");
let mut cache: ModelsCache = std::fs::read_to_string(&cache_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let now = now_secs();
let ollama = cached_or_scan(
&mut cache,
"ollama",
&ollama_key(),
now,
CACHE_TTL_SECS,
detect_ollama,
);
let llamacpp = cached_or_scan(
&mut cache,
"llama.cpp",
&llamacpp_key(),
now,
CACHE_TTL_SECS,
detect_llamacpp,
);
let lmstudio = cached_or_scan(
&mut cache,
"lm-studio",
&"api".to_string(),
now,
CACHE_TTL_SECS,
detect_lmstudio,
);
if let Ok(text) = serde_json::to_string_pretty(&cache) {
let _ = std::fs::write(&cache_path, text);
}
let mut runtimes = Vec::new();
let mut models = Vec::new();
for (info, ms) in [ollama, llamacpp, lmstudio] {
runtimes.push(info);
models.extend(ms);
}
ModelsInventory { runtimes, models }
}
/// Run `scan` unless a fresh cache entry exists; stores the result.
fn cached_or_scan(
cache: &mut ModelsCache,
runtime: &str,
key: &str,
now: u64,
ttl: u64,
scan: fn() -> Vec<RuntimeModel>,
) -> (RuntimeInfo, Vec<RuntimeModel>) {
let fresh = cache
.entries
.get(runtime)
.filter(|e| e.key == key && now.saturating_sub(e.fetched_at) < ttl);
if let Some(entry) = fresh {
return (entry.info.clone(), entry.models.clone());
}
let models = scan();
let info = runtime_info(runtime, &models);
cache.entries.insert(
runtime.to_string(),
CacheEntry {
key: key.to_string(),
fetched_at: now,
info: info.clone(),
models: models.clone(),
},
);
(info, models)
}
/// Describe a runtime from its models: detected when there is at least one
/// model (or the binary was found even with zero models).
fn runtime_info(runtime: &str, models: &[RuntimeModel]) -> RuntimeInfo {
match runtime {
"ollama" => RuntimeInfo {
runtime: runtime.to_string(),
detected: which::which("ollama").is_ok(),
note: if models.is_empty() {
Some("binary not found on PATH, or 'ollama list' returned nothing".to_string())
} else {
Some(format!("{} model(s) via 'ollama list'", models.len()))
},
},
"llama.cpp" => RuntimeInfo {
runtime: runtime.to_string(),
detected: !models.is_empty(),
note: if models.is_empty() {
Some("llama-server/llama-cli not found, or no .gguf under the model directories".to_string())
} else {
Some(format!("{} model(s) found on disk", models.len()))
},
},
_ => RuntimeInfo {
runtime: runtime.to_string(),
detected: !models.is_empty(),
note: if models.is_empty() {
Some("no response from http://127.0.0.1:1234/v1/models".to_string())
} else {
Some(format!("{} model(s) via the local API", models.len()))
},
},
}
}
/// Identity key of the ollama binary (path + mtime + size), so a stale
/// cache entry is never reused after an ollama update.
fn ollama_key() -> String {
which::which("ollama")
.map(|p| bin_key(&p))
.unwrap_or_else(|_| "missing".to_string())
}
/// Identity key of the llama.cpp binaries (llama-server or llama-cli).
fn llamacpp_key() -> String {
let bin = which::which("llama-server")
.or_else(|_| which::which("llama-cli"))
.unwrap_or_default();
if bin.as_os_str().is_empty() {
"missing".to_string()
} else {
bin_key(&bin)
}
}
/// Run `ollama list --format json` and parse the model list. Any failure
/// (binary missing, timeout, bad output) yields an empty list.
fn detect_ollama() -> Vec<RuntimeModel> {
let Ok(bin) = which::which("ollama") else {
return Vec::new();
};
let output = match run_capture(&bin, &["list", "--format", "json"]) {
Some(o) => o,
None => return Vec::new(),
};
parse_ollama_list(&output).unwrap_or_default()
}
/// Parse the `ollama list --format json` output.
fn parse_ollama_list(json: &str) -> Option<Vec<RuntimeModel>> {
#[derive(Deserialize)]
struct OllamaModel {
name: String,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
details: Option<OllamaDetails>,
#[serde(default)]
modified_at: Option<String>,
}
#[derive(Deserialize)]
struct OllamaDetails {
#[serde(default)]
quantization_level: Option<String>,
}
#[derive(Deserialize)]
struct List {
#[serde(default)]
models: Vec<OllamaModel>,
}
let list: List = serde_json::from_str(json).ok()?;
Some(
list.models
.into_iter()
.map(|m| RuntimeModel {
runtime: "ollama".to_string(),
name: m.name,
size_bytes: m.size,
quantization: m.details.and_then(|d| d.quantization_level),
last_used: m.modified_at,
path: String::new(),
})
.collect(),
)
}
/// Scan the local disk for llama.cpp models (.gguf files) under the known
/// model directories. The quantization is read from the file name when
/// possible; the last use is the file modification time.
fn detect_llamacpp() -> Vec<RuntimeModel> {
let _ = which::which("llama-server").or_else(|_| which::which("llama-cli"));
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(h) = crate::config::home_dir() {
dirs.push(h.join("models"));
dirs.push(h.join(".cache").join("llama.cpp"));
dirs.push(h.join(".llama").join("models"));
}
if let Some(d) = std::env::var_os("LLAMA_MODELS_DIR") {
dirs.push(PathBuf::from(d));
}
let mut out = Vec::new();
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
for dir in dirs {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let p = entry.path();
if !p.is_file() || !p.to_string_lossy().to_lowercase().ends_with(".gguf") {
continue;
}
if seen.insert(p.display().to_string(), ()).is_some() {
continue;
}
let meta = std::fs::metadata(&p).ok();
let size = meta.as_ref().map(|m| m.len());
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.map(rfc3339_from_system_time);
let name = p
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("model")
.to_string();
out.push(RuntimeModel {
runtime: "llama.cpp".to_string(),
name,
size_bytes: size,
quantization: quantization_from_name(&p.display().to_string()),
last_used: mtime,
path: p.display().to_string(),
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Extract a quantization token (Q4_K_M, Q8_0, ...) from a file name.
fn quantization_from_name(name: &str) -> Option<String> {
// Patterns: "Q4_K_M", "Q8_0", "q4_0", "IQ4_XS", "Q5_K_S", ...
let up = name.to_uppercase();
let bytes: Vec<char> = up.chars().collect();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == 'Q' && bytes[i + 1].is_ascii_digit() {
let start = i;
let mut j = i + 1;
let mut digits = 0;
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == '_') {
if bytes[j].is_ascii_digit() {
digits += 1;
}
j += 1;
}
// Suffix letters allowed: Q4_K_M, Q5_K_S, Q6_K, Q3_K_L, ...
while j < bytes.len()
&& (bytes[j].is_ascii_uppercase() || bytes[j] == '_')
&& j - start < 10
{
j += 1;
}
// Also match the "IQ4_XS" style (I + Q + digits).
let token_start = if start > 0 && bytes[start - 1] == 'I' {
start - 1
} else {
start
};
let token: String = bytes[token_start..j].iter().collect();
if digits >= 1
&& token.len() >= 3
&& token
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
{
return Some(token);
}
i = j;
} else {
i += 1;
}
}
None
}
/// Query the LM Studio local API (OpenAI-compatible, default port 1234).
fn detect_lmstudio() -> Vec<RuntimeModel> {
let url = std::env::var("LM_STUDIO_URL").unwrap_or_else(|_| "http://127.0.0.1:1234/v1/models".to_string());
let resp = ureq::get(&url)
.timeout(Duration::from_secs(3))
.call();
let Ok(resp) = resp else {
return Vec::new();
};
let Ok(text) = resp.into_string() else {
return Vec::new();
};
parse_lmstudio_list(&text).unwrap_or_default()
}
/// Parse the LM Studio /v1/models response: {"data": [{"id": ...}]}.
fn parse_lmstudio_list(json: &str) -> Option<Vec<RuntimeModel>> {
#[derive(Deserialize)]
struct LmModel {
id: String,
}
#[derive(Deserialize)]
struct List {
data: Vec<LmModel>,
}
let list: List = serde_json::from_str(json).ok()?;
Some(
list.data
.into_iter()
.map(|m| RuntimeModel {
runtime: "lm-studio".to_string(),
name: m.id,
size_bytes: None,
quantization: None,
last_used: None,
path: String::new(),
})
.collect(),
)
}
// ---------------------------------------------------------------------------
// Prune
// ---------------------------------------------------------------------------
/// Candidates for pruning: models not used within `days` days. The
/// default model of a runtime (any name tagged ":latest" for ollama, and
/// the first model of a runtime when it has no tag) is never a candidate.
pub fn prune_candidates(models: &[RuntimeModel], days: u64) -> Vec<&RuntimeModel> {
let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
models
.iter()
.filter(|m| !is_default_model(m))
.filter(|m| {
let Some(lu) = &m.last_used else {
// Unknown last use: keep the model (do not guess).
return false;
};
chrono::DateTime::parse_from_rfc3339(lu)
.map(|t| t.with_timezone(&chrono::Utc) < cutoff)
.unwrap_or(false)
})
.collect()
}
/// Is this model the default of its runtime (protected from pruning)?
fn is_default_model(m: &RuntimeModel) -> bool {
m.name.ends_with(":latest")
}
/// Remove one model from its runtime. ollama models are removed with
/// `ollama rm`; llama.cpp models are files that are deleted. LM Studio has
/// no deletion endpoint: an error explains why.
pub fn remove_model(m: &RuntimeModel) -> anyhow::Result<()> {
match m.runtime.as_str() {
"ollama" => {
let bin = which::which("ollama")
.map_err(|_| anyhow::anyhow!("ollama binary not found on PATH"))?;
let status = std::process::Command::new(&bin)
.arg("rm")
.arg(&m.name)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()?;
if status.success() {
Ok(())
} else {
Err(anyhow::anyhow!("'ollama rm {}' failed", m.name))
}
}
"llama.cpp" => {
if m.path.is_empty() {
return Err(anyhow::anyhow!(
"model '{}' has no known file path — nothing to delete",
m.name
));
}
std::fs::remove_file(&m.path)
.map_err(|e| anyhow::anyhow!("cannot delete {}: {e}", m.path))
}
_ => Err(anyhow::anyhow!(
"LM Studio has no deletion API — remove model '{}' from the LM Studio UI",
m.name
)),
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run a binary with arguments, capturing stdout, with a hard timeout.
/// Returns None when the binary fails, times out or cannot run.
fn run_capture(bin: &Path, args: &[&str]) -> Option<String> {
use std::io::Read;
let mut cmd = std::process::Command::new(bin);
cmd.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().ok()?;
let status = match child.wait_timeout(Duration::from_secs(8)) {
Ok(Some(s)) => s,
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Err(_) => return None,
};
if !status.success() {
return None;
}
let mut out = String::new();
child.stdout.take()?.read_to_string(&mut out).ok()?;
Some(out)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// RFC 3339 string of a system time (best effort).
fn rfc3339_from_system_time(t: SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
}
/// Identity key of a binary: path + modification time + size (probe.rs style).
fn bin_key(bin: &Path) -> String {
let meta = std::fs::metadata(bin).ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = meta.map(|m| m.len()).unwrap_or(0);
format!("{}|{mtime}|{size}", bin.display())
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ModelsCache {
entries: BTreeMap<String, CacheEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CacheEntry {
key: String,
fetched_at: u64,
info: RuntimeInfo,
models: Vec<RuntimeModel>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const OLLAMA_FIXTURE: &str = r#"{
"models": [
{
"name": "llama3.1:8b",
"size": 4661247078,
"details": { "quantization_level": "Q4_K_M" },
"modified_at": "2026-08-10T12:00:00Z"
},
{
"name": "llama3.1:latest",
"size": 4661247078,
"details": { "quantization_level": "Q4_K_M" },
"modified_at": "2026-08-17T12:00:00Z"
},
{
"name": "qwen2.5-coder:7b",
"size": 4700000000,
"details": { "quantization_level": "Q8_0" },
"modified_at": "2026-01-05T09:00:00Z"
}
]
}"#;
#[test]
fn parses_ollama_list() {
let models = parse_ollama_list(OLLAMA_FIXTURE).unwrap();
assert_eq!(models.len(), 3);
assert_eq!(models[0].name, "llama3.1:8b");
assert_eq!(models[0].runtime, "ollama");
assert_eq!(models[0].quantization.as_deref(), Some("Q4_K_M"));
assert_eq!(models[0].last_used.as_deref(), Some("2026-08-10T12:00:00Z"));
assert_eq!(models[0].size_bytes, Some(4661247078));
}
#[test]
fn parses_ollama_empty_list() {
let models = parse_ollama_list(r#"{"models": []}"#).unwrap();
assert!(models.is_empty());
}
#[test]
fn ollama_bad_output_yields_none() {
assert!(parse_ollama_list("not json").is_none());
}
#[test]
fn parses_lmstudio_list() {
let models = parse_lmstudio_list(
r#"{"object":"list","data":[{"id":"llama-3.1-8b","object":"model"},{"id":"codestral"}]}"#,
)
.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0].runtime, "lm-studio");
assert_eq!(models[0].name, "llama-3.1-8b");
assert_eq!(models[0].size_bytes, None);
}
#[test]
fn finds_models_exactly_and_by_short_name() {
let inv = ModelsInventory {
runtimes: vec![],
models: vec![
RuntimeModel { runtime: "ollama".into(), name: "llama3.1:8b".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() },
RuntimeModel { runtime: "ollama".into(), name: "llama3.1:latest".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() },
RuntimeModel { runtime: "lm-studio".into(), name: "codestral".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() },
],
};
assert_eq!(inv.find("llama3.1:8b").unwrap().name, "llama3.1:8b");
// Short name that is ambiguous (two llama3.1 variants) is not resolved.
assert!(inv.find("llama3.1").is_none());
// A short name matching exactly one model works.
assert_eq!(inv.find("codestral").unwrap().name, "codestral");
assert!(inv.find("nope").is_none());
}
#[test]
fn prune_skips_latest_and_recent_models() {
let models = parse_ollama_list(OLLAMA_FIXTURE).unwrap();
// qwen was last used in January: candidate; llama3.1:8b (Aug 10)
// is recent enough with 30 days; :latest is protected.
let cands = prune_candidates(&models, 30);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].name, "qwen2.5-coder:7b");
// With 7 days, the Aug 10 model becomes a candidate too.
let cands = prune_candidates(&models, 7);
let names: Vec<&str> = cands.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"llama3.1:8b"));
assert!(names.contains(&"qwen2.5-coder:7b"));
assert!(!names.contains(&"llama3.1:latest"));
}
#[test]
fn models_without_last_used_are_kept() {
let m = RuntimeModel { runtime: "lm-studio".into(), name: "x".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() };
assert!(prune_candidates(&[m.clone()], 1).is_empty());
// Unparsable timestamps are also kept.
let mut m2 = m.clone();
m2.last_used = Some("garbage".into());
assert!(prune_candidates(&[m2], 1).is_empty());
}
#[test]
fn quantization_is_read_from_file_names() {
assert_eq!(quantization_from_name("~/models/llama-3.1.Q4_K_M.gguf").as_deref(), Some("Q4_K_M"));
assert_eq!(quantization_from_name("model-q8_0.gguf").as_deref(), Some("Q8_0"));
assert_eq!(quantization_from_name("plain.gguf"), None);
}
#[test]
fn bin_key_is_stable_and_distinct() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
std::fs::write(&a, b"abc").unwrap();
let k1 = bin_key(&a);
std::fs::write(&a, b"abcd").unwrap();
let k2 = bin_key(&a);
assert_ne!(k1, k2);
}
#[test]
fn cache_roundtrip() {
let mut cache = ModelsCache::default();
let info = RuntimeInfo { runtime: "ollama".into(), detected: true, note: None };
cache.entries.insert(
"ollama".into(),
CacheEntry { key: "k".into(), fetched_at: 42, info, models: vec![] },
);
let text = serde_json::to_string(&cache).unwrap();
let back: ModelsCache = serde_json::from_str(&text).unwrap();
assert_eq!(back.entries["ollama"].fetched_at, 42);
}
}
+425
View File
@@ -0,0 +1,425 @@
//! Playbooks (issue #51): export a range of REPL history to a re-runnable
//! YAML playbook, then replay it step by step with per-step confirmation.
//! Variables `{{var}}` are substituted at replay time; a failing step stops
//! the playbook with a non-zero exit code.
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
/// One playbook step: a command plus the directory it runs in.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookStep {
/// Short human label (defaults to the command when absent).
#[serde(default)]
pub name: String,
/// The full REPL line: an am command or a shell command.
pub command: String,
/// Working directory of the step (absent = current directory).
#[serde(default)]
pub cwd: Option<String>,
}
/// A playbook document (YAML file format of `am history --save`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playbook {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub steps: Vec<PlaybookStep>,
}
/// Directory where playbooks live: <state_dir>/playbooks/.
pub fn dir(app: &crate::app::App) -> PathBuf {
app.events_dir().join("playbooks")
}
/// Resolve the destination path: a bare file name goes into the playbooks
/// directory, an explicit path is honored as-is.
pub fn resolve_dest(app: &crate::app::App, dest: &Path) -> PathBuf {
let has_sep = dest
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir))
|| dest.components().count() > 1;
if has_sep {
dest.to_path_buf()
} else {
dir(app).join(dest)
}
}
/// Split a command line into words, honoring single and double quotes.
pub fn split_words(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut started = false;
for c in line.chars() {
match quote {
Some(q) if c == q => quote = None,
Some(_) => cur.push(c),
None => match c {
'\'' | '"' => {
quote = Some(c);
started = true;
}
c if c.is_whitespace() => {
if started {
out.push(std::mem::take(&mut cur));
started = false;
}
}
c => {
cur.push(c);
started = true;
}
},
}
}
if started {
out.push(cur);
}
out
}
/// Export the history range [from..=to] (1-based indices, newest first, as
/// shown by `am history`) into a playbook file. Steps are written in
/// chronological order. Returns (step count, written path).
pub fn export_range(
app: &crate::app::App,
from: usize,
to: usize,
dest: &Path,
) -> Result<(usize, PathBuf)> {
let all = crate::history::read_all(app, 0);
if all.is_empty() {
anyhow::bail!("history is empty — nothing to export");
}
let n = all.len();
let lo = from.min(n).max(1);
let hi = to.min(n).max(lo);
let mut slice: Vec<&crate::history::HistoryEntry> = all[lo - 1..=hi - 1]
.iter()
.collect();
slice.reverse(); // chronological order (oldest first)
if slice.is_empty() {
anyhow::bail!("range {from}..{to} is outside the history (1..={n})");
}
let steps: Vec<PlaybookStep> = slice
.iter()
.enumerate()
.map(|(i, e)| PlaybookStep {
name: format!("step {}", i + 1),
command: e.cmd.clone(),
cwd: e.cwd.clone(),
})
.collect();
let step_count = steps.len();
let pb = Playbook {
name: dest
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "playbook".to_string()),
description: Some(format!("exported from am history {from}..{to}")),
steps,
};
let yaml = serde_yaml::to_string(&pb)?;
let path = resolve_dest(app, dest);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", parent.display()))?;
}
std::fs::write(&path, yaml).with_context(|| format!("cannot write {}", path.display()))?;
Ok((step_count, path))
}
/// Collect every `{{var}}` placeholder in the playbook.
fn collect_vars(pb: &Playbook) -> Vec<String> {
let mut vars: Vec<String> = Vec::new();
for s in &pb.steps {
for text in [&s.command, s.cwd.as_deref().unwrap_or("")] {
let mut rest: &str = text;
while let Some(start) = rest.find("{{") {
if let Some(end) = rest[start + 2..].find("}}") {
let name = rest[start + 2..start + 2 + end].trim().to_string();
if !name.is_empty() && !vars.contains(&name) {
vars.push(name);
}
rest = &rest[start + 2 + end + 2..];
} else {
break;
}
}
}
}
vars
}
/// Substitute `{{var}}` placeholders with the provided values.
fn substitute(text: &str, values: &BTreeMap<String, String>) -> String {
let mut out = text.to_string();
for (k, v) in values {
out = out.replace(&format!("{{{{{k}}}}}"), v);
}
out
}
/// Replay a playbook: each step is confirmed (unless --yes) and executed.
/// Returns the exit code: 0 when every step succeeded, otherwise the
/// non-zero exit code of the failing step.
pub fn run_playbook(
app: &crate::app::App,
path: &Path,
vars: &BTreeMap<String, String>,
) -> Result<i32> {
if !path.exists() {
anyhow::bail!("playbook not found: {}", path.display());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut pb: Playbook = serde_yaml::from_str(&text)
.with_context(|| format!("{}: invalid playbook YAML", path.display()))?;
if pb.steps.is_empty() {
anyhow::bail!("{}: playbook has no steps", path.display());
}
// Resolve variables: explicit values first, then ask on stdin.
let mut values = vars.clone();
for v in collect_vars(&pb) {
if values.contains_key(&v) {
continue;
}
eprint!("value for {v}: ");
std::io::Write::flush(&mut std::io::stderr()).ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
let val = line.trim().to_string();
values.insert(v.clone(), val);
}
app.log
.info(&format!("playbook '{}' — {} step(s)", pb.name, pb.steps.len()));
let total = pb.steps.len();
for (i, step) in pb.steps.iter_mut().enumerate() {
step.command = substitute(&step.command, &values);
step.cwd = step.cwd.as_deref().map(|c| substitute(c, &values));
let label = if step.name.is_empty() {
step.command.clone()
} else {
format!("{} — {}", step.name, step.command)
};
app.log.info(&format!("▶ step {}/{}: {label}", i + 1, total));
if !app.confirm("run this step?")? {
app.log.info("playbook stopped by user");
return Ok(2);
}
let code = execute_step(app, step)?;
if code != 0 {
app.log
.error(&format!("step {} failed (exit {code}) — playbook stopped", i + 1));
return Ok(code);
}
}
app.log.success(&format!("playbook '{}' finished", pb.name));
Ok(0)
}
/// Run one step: an am command is re-invoked through the current binary,
/// anything else goes through the system shell in the step directory.
fn execute_step(app: &crate::app::App, step: &PlaybookStep) -> Result<i32> {
let cwd = step.cwd.as_deref().map(PathBuf::from);
if cwd.as_ref().is_some_and(|p| !p.is_dir()) {
anyhow::bail!("step cwd does not exist: {}", cwd.as_ref().unwrap().display());
}
let words = split_words(&step.command);
let first = words.first().map(|s| s.as_str()).unwrap_or("");
let status = if crate::repl::is_am_command(first) {
// Re-invoke the am binary itself (same behavior as the REPL).
let mut cmd = Command::new(std::env::current_exe()?);
cmd.args(&words);
if let Some(d) = &cwd {
cmd.current_dir(d);
}
cmd.status()
} else {
// System shell, like the REPL gateway.
#[cfg(windows)]
let mut cmd = {
let mut c = Command::new("cmd");
c.arg("/C");
c.arg(&step.command);
c
};
#[cfg(not(windows))]
let mut cmd = {
let mut c = Command::new("sh");
c.arg("-c");
c.arg(&step.command);
c
};
if let Some(d) = &cwd {
cmd.current_dir(d);
}
cmd.status()
};
let status = status.with_context(|| format!("cannot execute '{}'", step.command))?;
Ok(status.code().unwrap_or(1))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_words_handles_quotes() {
assert_eq!(split_words("stats --costs"), vec!["stats", "--costs"]);
assert_eq!(split_words("echo \"hello world\""), vec!["echo", "hello world"]);
assert_eq!(split_words("run claude-code --args 'a b'"), vec!["run", "claude-code", "--args", "a b"]);
assert_eq!(split_words(" ls -la "), vec!["ls", "-la"]);
assert_eq!(split_words(""), Vec::<String>::new());
}
#[test]
fn collect_and_substitute_vars() {
let pb = Playbook {
name: "t".into(),
description: None,
steps: vec![
PlaybookStep { name: "a".into(), command: "run {{agent}}".into(), cwd: None },
PlaybookStep { name: "b".into(), command: "cd {{dir}} && ls".into(), cwd: Some("{{dir}}".into()) },
PlaybookStep { name: "c".into(), command: "no vars".into(), cwd: None },
],
};
let vars = collect_vars(&pb);
assert_eq!(vars, vec!["agent".to_string(), "dir".to_string()]);
let mut values = BTreeMap::new();
values.insert("agent".to_string(), "claude-code".to_string());
values.insert("dir".to_string(), "/tmp".to_string());
assert_eq!(substitute("run {{agent}}", &values), "run claude-code");
assert_eq!(substitute("cd {{dir}} && ls", &values), "cd /tmp && ls");
assert_eq!(substitute("no vars", &values), "no vars");
}
fn test_app(tag: &str) -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = crate::app::App::from_cli(cli).expect("app should build");
app.paths.state_file = dir.path().join(format!("state-{tag}/state.json"));
app.paths.config_dir = Some(dir.path().to_path_buf());
app
}
#[test]
fn export_range_writes_valid_playbook() {
let app = test_app("exp");
let state = app.events_dir();
std::fs::create_dir_all(state.join("history")).unwrap();
// Write 5 history entries (file lines are read oldest first).
let lines: Vec<String> = (1..=5)
.map(|i| {
format!(
"{{\"ts\":\"2026-08-18T10:0{i}:00Z\",\"cmd\":\"cmd {i}\",\"cwd\":\"/tmp/p{i}\"}}"
)
})
.collect();
std::fs::write(
state.join("history/20260818.jsonl"),
lines.join("\n") + "\n",
)
.unwrap();
// read_all returns newest first: [cmd5..cmd1]; range 2..4 = cmd4,cmd3,cmd2.
let dest = state.join("pb.yaml");
let (n, path) = export_range(&app, 2, 4, &dest).unwrap();
assert_eq!(n, 3);
let text = std::fs::read_to_string(&path).unwrap();
let pb: Playbook = serde_yaml::from_str(&text).unwrap();
assert_eq!(pb.name, "pb");
assert_eq!(pb.steps.len(), 3);
// Chronological order: cmd2 first, then cmd3, then cmd4.
assert_eq!(pb.steps[0].command, "cmd 2");
assert_eq!(pb.steps[1].command, "cmd 3");
assert_eq!(pb.steps[2].command, "cmd 4");
assert_eq!(pb.steps[2].cwd.as_deref(), Some("/tmp/p4"));
}
#[test]
fn export_range_bounds_are_clamped() {
let app = test_app("clamp");
let state = app.events_dir();
std::fs::create_dir_all(state.join("history")).unwrap();
let lines: Vec<String> = (1..=3)
.map(|i| format!("{{\"ts\":\"2026-08-18T10:0{i}:00Z\",\"cmd\":\"c{i}\"}}"))
.collect();
std::fs::write(state.join("history/20260818.jsonl"), lines.join("\n") + "\n").unwrap();
let dest = state.join("pb2.yaml");
// Range beyond the history size: clamped to the available entries.
let (n, _) = export_range(&app, 1, 99, &dest).unwrap();
assert_eq!(n, 3);
// Invalid range (from > to) is clamped too.
let (n2, _) = export_range(&app, 3, 1, &dest).unwrap();
assert_eq!(n2, 1);
}
#[test]
fn replay_runs_steps_and_stops_on_failure() {
let app = test_app("replay");
// --yes so confirmations pass automatically.
let mut yes_app = app;
yes_app.cli.yes = true;
// Playbook with two harmless shell steps.
let dir = std::env::temp_dir().join(format!("am-pb-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pb_path = dir.join("ok.yaml");
std::fs::write(
&pb_path,
"name: ok\ndescription: t\nsteps:\n - name: one\n command: \"echo one\"\n - name: two\n command: \"echo two\"\n",
)
.unwrap();
let code = run_playbook(&yes_app, &pb_path, &BTreeMap::new()).unwrap();
assert_eq!(code, 0);
// A failing step stops the playbook with its exit code.
let bad_path = dir.join("bad.yaml");
std::fs::write(
&bad_path,
"name: bad\nsteps:\n - name: ok\n command: \"echo hi\"\n - name: boom\n command: \"exit 3\"\n - name: never\n command: \"echo never\"\n",
)
.unwrap();
let code = run_playbook(&yes_app, &bad_path, &BTreeMap::new()).unwrap();
assert_eq!(code, 3);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bare_dest_goes_to_playbooks_dir() {
let app = test_app("dest");
// A bare file name is resolved under <state>/playbooks/.
let resolved = resolve_dest(&app, std::path::Path::new("deploy.yaml"));
assert_eq!(resolved, app.events_dir().join("playbooks").join("deploy.yaml"));
// An explicit path is honored as-is.
let abs = std::path::PathBuf::from("C:/tmp/x.yaml");
assert_eq!(resolve_dest(&app, &abs), abs);
}
}
+794
View File
@@ -0,0 +1,794 @@
//! Plugin scripts (issue #75): executable scripts in `<state_dir>/plugins/`
//! subscribed to lifecycle events, complementing the axe-6 hooks.
//!
//! Contract: the event JSON arrives on stdin, the plugin answers with JSON
//! on stdout. Failures are non-blocking (a warning at most) and a per-plugin
//! timeout kills runaway scripts. While a plugin runs, the `AM_PLUGINS_RUNNING`
//! guard is exported so a nested `am` process skips plugins (no recursion).
//!
//! Layout of the directory:
//! ```text
//! state_dir/plugins/
//! notify.sh # the plugin itself (any executable, or a marker when
//! # the manifest declares a `command`)
//! notify.yaml # optional sidecar manifest: events, timeout_secs, command
//! ```
use crate::app::App;
use crate::config::PluginSettings;
use crate::events::{Event, EventKind};
use serde::Serialize;
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
/// Default timeout for one plugin run (overridable in settings or manifest).
pub const DEFAULT_TIMEOUT_SECS: u64 = 10;
/// Name of the plugins directory inside the state directory.
pub const DIR_NAME: &str = "plugins";
/// Recursion guard: while a plugin runs, nested am processes skip plugins.
pub const RECURSION_GUARD: &str = "AM_PLUGINS_RUNNING";
/// Events plugins can subscribe to (lifecycle, same names as the axe-6 hooks).
pub const EVENT_NAMES: &[&str] = &["on_install", "on_start", "on_stop", "on_update"];
/// Map a journal event kind to the plugin event name it fires.
fn event_name(kind: EventKind) -> Option<&'static str> {
match kind {
EventKind::Install => Some("on_install"),
EventKind::Start => Some("on_start"),
EventKind::Stop => Some("on_stop"),
EventKind::Update => Some("on_update"),
_ => None,
}
}
/// One discovered plugin.
#[derive(Debug, Clone)]
pub struct Plugin {
pub name: String,
/// Script file (also the discovery anchor when a manifest is present).
pub file: PathBuf,
/// Optional command prefix from the manifest (e.g. "python notify.py");
/// when absent the script file itself is executed.
pub command: Option<String>,
/// Events the plugin subscribes to (sorted, deduplicated).
pub events: Vec<String>,
/// Maximum runtime before the plugin is killed.
pub timeout: Duration,
}
/// Sidecar manifest of one plugin (`<name>.yaml` next to the script).
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
#[serde(default)]
command: Option<String>,
#[serde(default)]
events: Option<Vec<String>>,
#[serde(default)]
timeout_secs: Option<u64>,
}
/// The plugins directory of the state directory.
pub fn dir(app: &App) -> PathBuf {
app.events_dir().join(DIR_NAME)
}
fn is_manifest_ext(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml" | "yml" | "json")
)
}
/// Discover the enabled plugins of a directory, sorted by name. Manifest
/// files (yaml/yml/json) are consumed as sidecars of their script and never
/// listed as plugins themselves.
pub fn discover(app: &App, dir: &Path) -> Vec<Plugin> {
let enabled_filter: Option<Vec<String>> = app
.config
.settings
.plugins
.as_ref()
.and_then(|p: &PluginSettings| p.enabled.clone())
.filter(|l| !l.is_empty());
let default_timeout = app
.config
.settings
.plugins
.as_ref()
.and_then(|p: &PluginSettings| p.timeout_secs)
.unwrap_or(DEFAULT_TIMEOUT_SECS);
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!name.starts_with('.')
})
.collect()
})
.unwrap_or_default();
entries.sort();
let mut out = Vec::new();
for path in entries {
if !path.is_file() || is_manifest_ext(&path) {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let stem = stem.to_string();
let manifest = load_manifest(&path, &stem);
let mut events = match &manifest.events {
Some(list) => list
.iter()
.filter(|e| EVENT_NAMES.contains(&e.as_str()))
.cloned()
.collect::<Vec<String>>(),
None => EVENT_NAMES.iter().map(|s| s.to_string()).collect(),
};
events.sort();
events.dedup();
if events.is_empty() {
app.log.warn(&format!(
"plugin '{stem}': no valid event in its manifest (valid: {}) — ignored",
EVENT_NAMES.join(", ")
));
continue;
}
if let Some(filter) = &enabled_filter {
if !filter.iter().any(|n| n == &stem) {
continue;
}
}
let timeout = manifest.timeout_secs.unwrap_or(default_timeout).max(1);
out.push(Plugin {
name: stem,
file: path,
command: manifest.command,
events,
timeout: Duration::from_secs(timeout),
});
}
out
}
/// Load the sidecar manifest `<stem>.yaml|yml|json` next to the script.
/// An unreadable or invalid manifest silently yields the defaults.
fn load_manifest(script: &Path, stem: &str) -> Manifest {
let parent = script.parent().unwrap_or(Path::new("."));
for ext in ["yaml", "yml", "json"] {
let m = parent.join(format!("{stem}.{ext}"));
if let Ok(text) = std::fs::read_to_string(&m) {
if let Ok(manifest) = serde_yaml::from_str::<Manifest>(&text) {
return manifest;
}
}
}
Manifest::default()
}
/// A slice is empty (serde helper for `skip_serializing_if` on `&[T]`).
fn slice_is_empty<T>(s: &[T]) -> bool {
s.is_empty()
}
/// Payload sent to a plugin on stdin (mirrors the journal event fields).
#[derive(Debug, Serialize)]
pub struct PluginInput<'a> {
/// Plugin event name, e.g. "on_start".
pub event: &'a str,
/// Journal kind, e.g. "start".
pub kind: &'a str,
pub ts: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'a str>,
#[serde(skip_serializing_if = "slice_is_empty")]
pub args: &'a [String],
#[serde(skip_serializing_if = "slice_is_empty")]
pub env_keys: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_s: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
}
impl<'a> PluginInput<'a> {
fn from_event(event_name: &'a str, ev: &'a Event) -> Self {
Self {
event: event_name,
kind: ev.kind.as_str(),
ts: &ev.ts,
agent: ev.agent.as_deref(),
pid: ev.pid,
cwd: ev.cwd.as_deref(),
project: ev.project.as_deref(),
session: ev.session.as_deref(),
reason: ev.reason.as_deref(),
args: &ev.args,
env_keys: &ev.env_keys,
exit_code: ev.exit_code,
duration_s: ev.duration_s,
tokens_in: ev.tokens_in,
tokens_out: ev.tokens_out,
cost_usd: ev.cost_usd,
}
}
}
/// The JSON response contract a plugin answers with on stdout.
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct PluginResponse {
#[serde(default)]
pub ok: Option<bool>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub data: Option<serde_json::Value>,
}
/// Outcome of one plugin run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginStatus {
Ok,
Failed,
Timeout,
}
impl PluginStatus {
pub fn as_str(&self) -> &'static str {
match self {
PluginStatus::Ok => "ok",
PluginStatus::Failed => "failed",
PluginStatus::Timeout => "timeout",
}
}
}
/// Full result of one plugin run (used by `am plugins --test` and the logs).
#[derive(Debug)]
pub struct PluginResult {
pub status: PluginStatus,
pub exit_code: Option<i32>,
pub response: Option<PluginResponse>,
pub stdout: String,
pub stderr: String,
/// Set when the plugin could not be spawned or the wait failed.
pub error: Option<String>,
pub duration_ms: u64,
}
impl PluginResult {
fn error(msg: impl Into<String>) -> Self {
Self {
status: PluginStatus::Failed,
exit_code: None,
response: None,
stdout: String::new(),
stderr: String::new(),
error: Some(msg.into()),
duration_ms: 0,
}
}
}
/// Fire the plugins subscribed to this event (called from `App::emit`).
/// Non-blocking: every failure is logged as a warning, never fatal.
pub fn dispatch(app: &App, event: &Event) {
let Some(name) = event_name(event.kind) else {
return;
};
if std::env::var_os(RECURSION_GUARD).is_some() {
return; // a plugin is already running: never recurse
}
if app.dry_run() {
return;
}
let dir = dir(app);
if !dir.is_dir() {
return;
}
for plugin in discover(app, &dir) {
if !plugin.events.iter().any(|e| e == name) {
continue;
}
let result = run_plugin(app, &plugin, name, event);
report(app, &plugin, name, &result);
}
}
/// Run one plugin against one event with the JSON contract. Never fails:
/// every outcome is returned and the caller decides how to report it.
pub fn run_plugin(app: &App, plugin: &Plugin, event_name: &str, event: &Event) -> PluginResult {
let started = Instant::now();
let input = PluginInput::from_event(event_name, event);
let input_json = match serde_json::to_string(&input) {
Ok(j) => j,
Err(e) => {
return PluginResult::error(format!("cannot serialize the event payload: {e:#}"));
}
};
let (prog, args) = match &plugin.command {
Some(cmd) => {
let tokens = crate::playbook::split_words(cmd);
if tokens.is_empty() {
return PluginResult::error("empty command in the plugin manifest");
}
let (p, pre) = crate::runner::resolve_program(&tokens[0]);
let mut a = pre;
a.extend(tokens[1..].iter().cloned());
(p, a)
}
None => {
let (p, pre) = crate::runner::resolve_program(&plugin.file.display().to_string());
(p, pre)
}
};
let mut env = BTreeMap::new();
for (k, v) in std::env::vars_os() {
env.insert(
k.to_string_lossy().to_string(),
v.to_string_lossy().to_string(),
);
}
env.insert(RECURSION_GUARD.to_string(), "1".to_string());
let mut cmd = Command::new(&prog);
cmd.args(&args)
.envs(&env)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// The plugin runs in its own directory: relative paths in the manifest
// command (e.g. "python3 notify.py") resolve next to the script.
if let Some(parent) = plugin.file.parent() {
cmd.current_dir(parent);
}
set_process_group(&mut cmd);
app.log
.verbose(&format!("plugin '{}': {} {event_name}", plugin.name, prog));
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return PluginResult::error(format!(
"cannot run {}: {e:#}",
plugin.file.display()
));
}
};
// Feed the event JSON, then close stdin (EOF).
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input_json.as_bytes());
}
let stdout = read_pipe(child.stdout.take());
let stderr = read_pipe(child.stderr.take());
let deadline = started + plugin.timeout;
let mut timed_out = false;
let mut exit_code: Option<i32> = None;
loop {
match child.wait_timeout(Duration::from_millis(200)) {
Ok(Some(status)) => {
exit_code = status.code();
break;
}
Ok(None) => {
if Instant::now() >= deadline {
crate::process::kill_tree(child.id());
let _ = child.wait();
timed_out = true;
break;
}
}
Err(e) => {
crate::process::kill_tree(child.id());
let _ = child.wait();
return PluginResult::error(format!("cannot wait for the plugin: {e:#}"));
}
}
}
let out = stdout.join().unwrap_or_default();
let err_out = stderr.join().unwrap_or_default();
let duration_ms = started.elapsed().as_millis() as u64;
if timed_out {
return PluginResult {
status: PluginStatus::Timeout,
exit_code: None,
response: None,
stdout: out,
stderr: err_out,
error: Some(format!(
"timeout after {}s — killed",
plugin.timeout.as_secs()
)),
duration_ms,
};
}
let code = exit_code.unwrap_or(-1);
let response = parse_response(&out);
PluginResult {
status: if code == 0 {
PluginStatus::Ok
} else {
PluginStatus::Failed
},
exit_code: Some(code),
response,
stdout: out,
stderr: err_out,
error: None,
duration_ms,
}
}
/// Log the outcome of a plugin run as warnings (never fatal).
fn report(app: &App, plugin: &Plugin, event_name: &str, r: &PluginResult) {
let who = format!("plugin '{}' on {event_name}", plugin.name);
match r.status {
PluginStatus::Ok => {
let resp = r.response.as_ref();
let message = resp.and_then(|x| x.message.clone());
let error = resp.and_then(|x| x.error.clone());
let ok_flag = resp.and_then(|x| x.ok).unwrap_or(true);
if let Some(err) = error {
app.log.warn(&format!("{who}: {err}"));
} else if !ok_flag {
app.log.warn(&format!(
"{who}: returned ok=false{}",
message
.map(|m| format!(" ({m})"))
.unwrap_or_default()
));
} else if let Some(m) = message {
app.log
.verbose(&format!("{who}: ok in {}ms — {m}", r.duration_ms));
} else {
app.log
.verbose(&format!("{who}: ok in {}ms", r.duration_ms));
}
}
PluginStatus::Failed => {
let detail = r
.response
.as_ref()
.and_then(|x| x.error.clone())
.or_else(|| {
let t = r.stderr.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
})
.unwrap_or_default();
app.log.warn(&format!(
"{who} failed (exit {}){}",
r.exit_code.unwrap_or(-1),
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
PluginStatus::Timeout => {
app.log.warn(&format!(
"{who}: {}",
r.error.as_deref().unwrap_or("timed out")
));
}
}
if let Some(e) = &r.error {
if r.status != PluginStatus::Timeout {
app.log.warn(&format!("{who}: {e}"));
}
}
if r.response.is_none() && !r.stdout.trim().is_empty() {
app.log.warn(&format!(
"{who}: invalid plugin output (expected a JSON object on stdout): {}",
crate::output::truncate(r.stdout.trim(), 80)
));
}
}
/// Parse the plugin stdout as the response contract (a JSON object).
fn parse_response(out: &str) -> Option<PluginResponse> {
let trimmed = out.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
if !value.is_object() {
return None;
}
serde_json::from_value(value).ok()
}
/// Read a child pipe to the end on a dedicated thread (avoids pipe deadlocks
/// while the parent waits with a timeout).
fn read_pipe<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
if let Some(mut p) = pipe {
let _ = p.read_to_string(&mut buf);
}
buf
})
}
/// Put the child in its own process group so `kill_tree` can stop it with
/// its whole subtree.
fn set_process_group(cmd: &mut Command) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
fn app_with_plugins(tag: &str) -> (App, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
std::mem::forget(dir);
let cfg = root.join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = App::from_cli(cli).unwrap();
app.paths.state_file = root.join(format!("state-{tag}.json"));
app.paths.log_dir = root.join("logs");
let pdir = app.events_dir().join(DIR_NAME);
std::fs::create_dir_all(&pdir).unwrap();
(app, pdir)
}
/// Write a script plus a manifest running it through the system shell
/// with an absolute path (a bare .sh file cannot be spawned directly on
/// Windows; the `command` override is the portable way).
fn write_shell_plugin(pdir: &Path, name: &str, script: &str, manifest_extra: &str) {
let script_path = pdir.join(format!("{name}.sh"));
std::fs::write(&script_path, script).unwrap();
let abs = script_path.display().to_string().replace('\\', "/");
std::fs::write(
pdir.join(format!("{name}.yaml")),
format!("command: \"sh {abs}\"\n{manifest_extra}"),
)
.unwrap();
}
#[test]
fn manifest_events_and_timeout_are_parsed() {
let (app, pdir) = app_with_plugins("man");
std::fs::write(pdir.join("notify.sh"), "#!/bin/sh\ncat\n").unwrap();
std::fs::write(
pdir.join("notify.yaml"),
"events: [on_start, on_stop]\ntimeout_secs: 3\n",
)
.unwrap();
let plugins = discover(&app, &pdir);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "notify");
assert_eq!(
plugins[0].events,
vec!["on_start".to_string(), "on_stop".to_string()]
);
assert_eq!(plugins[0].timeout.as_secs(), 3);
// The manifest itself is never a plugin.
assert!(!plugins.iter().any(|p| p.name == "notify.yaml"));
}
#[test]
fn defaults_are_all_events_and_ten_seconds() {
let (app, pdir) = app_with_plugins("def");
std::fs::write(pdir.join("all.sh"), "#!/bin/sh\ncat\n").unwrap();
let plugins = discover(&app, &pdir);
assert_eq!(plugins.len(), 1);
assert_eq!(
plugins[0].events,
EVENT_NAMES.iter().map(|s| s.to_string()).collect::<Vec<_>>()
);
assert_eq!(plugins[0].timeout.as_secs(), DEFAULT_TIMEOUT_SECS);
}
#[test]
fn enabled_filter_and_settings_timeout_apply() {
let (mut app, pdir) = app_with_plugins("en");
std::fs::write(pdir.join("a.sh"), "#!/bin/sh\ncat\n").unwrap();
std::fs::write(pdir.join("b.sh"), "#!/bin/sh\ncat\n").unwrap();
app.config.settings.plugins = Some(PluginSettings {
timeout_secs: Some(7),
enabled: Some(vec!["b".to_string()]),
});
let plugins = discover(&app, &pdir);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "b");
assert_eq!(plugins[0].timeout.as_secs(), 7);
}
#[test]
fn unknown_events_are_filtered_out() {
let (app, pdir) = app_with_plugins("unk");
std::fs::write(pdir.join("x.sh"), "#!/bin/sh\ncat\n").unwrap();
std::fs::write(pdir.join("x.yaml"), "events: [on_boom, on_start]\n").unwrap();
let plugins = discover(&app, &pdir);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].events, vec!["on_start".to_string()]);
}
#[test]
fn plugin_runs_with_the_json_contract() {
let (app, pdir) = app_with_plugins("run");
write_shell_plugin(
&pdir,
"echo",
"#!/bin/sh\ncat >/dev/null\necho '{\"ok\": true, \"message\": \"pong\"}'\n",
"",
);
let plugins = discover(&app, &pdir);
assert_eq!(plugins.len(), 1);
let ev = Event::now(EventKind::Start)
.with_agent("claude-code")
.with_pid(42);
let r = run_plugin(&app, &plugins[0], "on_start", &ev);
assert_eq!(r.status, PluginStatus::Ok, "stderr: {}", r.stderr);
assert_eq!(r.exit_code, Some(0));
let resp = r.response.expect("a JSON response");
assert_eq!(resp.ok, Some(true));
assert_eq!(resp.message.as_deref(), Some("pong"));
}
#[test]
fn failing_plugin_is_reported_without_panicking() {
let (app, pdir) = app_with_plugins("fail");
write_shell_plugin(
&pdir,
"fail",
"#!/bin/sh\necho '{\"ok\": false, \"error\": \"boom\"}' >&2\nexit 3\n",
"",
);
let plugins = discover(&app, &pdir);
let ev = Event::now(EventKind::Start);
let r = run_plugin(&app, &plugins[0], "on_start", &ev);
assert_eq!(r.status, PluginStatus::Failed);
assert_eq!(r.exit_code, Some(3));
// dispatch only warns — it never returns an error or panics.
dispatch(&app, &ev);
}
#[test]
fn invalid_output_is_signaled_without_breaking_am() {
let (app, pdir) = app_with_plugins("bad");
write_shell_plugin(
&pdir,
"bad",
"#!/bin/sh\ncat >/dev/null\necho 'pas du json'\n",
"",
);
let plugins = discover(&app, &pdir);
let ev = Event::now(EventKind::Start);
let r = run_plugin(&app, &plugins[0], "on_start", &ev);
assert_eq!(r.status, PluginStatus::Ok); // exit 0: the run itself is fine
assert!(r.response.is_none(), "non-JSON output has no response");
dispatch(&app, &ev); // warning logged, nothing panics
}
#[test]
fn timeout_is_enforced_and_kills_the_plugin() {
let (app, pdir) = app_with_plugins("slow");
write_shell_plugin(
&pdir,
"slow",
"#!/bin/sh\nwhile true; do :; done\n",
"timeout_secs: 1\n",
);
let plugins = discover(&app, &pdir);
assert_eq!(plugins[0].timeout.as_secs(), 1);
let ev = Event::now(EventKind::Start);
let r = run_plugin(&app, &plugins[0], "on_start", &ev);
assert_eq!(r.status, PluginStatus::Timeout);
assert!(r.error.as_deref().unwrap_or("").contains("timeout"));
}
#[test]
fn command_override_runs_through_the_shell() {
let (app, pdir) = app_with_plugins("cmd");
// The manifest command wins over the file: the file is only a
// marker, the command does the work (portable on every OS).
std::fs::write(pdir.join("echo.sh"), "this is not a script\n").unwrap();
let worker = pdir.parent().unwrap().join("worker.sh");
std::fs::write(
&worker,
"#!/bin/sh\ncat >/dev/null\necho '{\"ok\": true, \"message\": \"via-command\"}'\n",
)
.unwrap();
let abs = worker.display().to_string().replace('\\', "/");
std::fs::write(pdir.join("echo.yaml"), format!("command: \"sh {abs}\"\n")).unwrap();
let plugins = discover(&app, &pdir);
let ev = Event::now(EventKind::Update).with_agent("aider");
let r = run_plugin(&app, &plugins[0], "on_update", &ev);
assert_eq!(r.status, PluginStatus::Ok, "stderr: {}", r.stderr);
let resp = r.response.expect("a JSON response");
assert_eq!(resp.message.as_deref(), Some("via-command"));
}
#[test]
fn dispatch_skips_other_kinds_and_missing_dir() {
let (app, pdir) = app_with_plugins("skip");
// No plugins dir: dispatch is a no-op.
std::fs::remove_dir_all(&pdir).unwrap();
dispatch(&app, &Event::now(EventKind::Start));
// A kind with no plugin event (run) is a no-op too.
dispatch(&app, &Event::now(EventKind::Run));
}
#[test]
fn recursion_guard_skips_dispatch() {
let (app, pdir) = app_with_plugins("guard");
write_shell_plugin(
&pdir,
"echo",
"#!/bin/sh\ncat >/dev/null\necho '{\"ok\": true}'\n",
"",
);
std::env::set_var(RECURSION_GUARD, "1");
dispatch(&app, &Event::now(EventKind::Start)); // must not run anything
std::env::remove_var(RECURSION_GUARD);
dispatch(&app, &Event::now(EventKind::Start)); // normal path: no panic
}
}
+21
View File
@@ -90,6 +90,27 @@ pub fn stop_pid(pid: u32, force: bool, timeout_secs: u64) -> bool {
!is_running(pid)
}
/// Kill a process and its whole tree (process group on unix, taskkill /T on
/// Windows). Used by the plugin runner and the lab benchmark to stop
/// runaway scripts and timed-out task runs.
pub fn kill_tree(pid: u32) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let _ = Command::new("kill")
.arg("-KILL")
.arg(format!("-{pid}")) // negative pid = the process group
.status();
let _ = Command::new("kill").arg("-KILL").arg(pid.to_string()).status();
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.status();
}
}
/// Start a program detached: stdout/stderr are redirected to the agent log
/// file. Returns the PID. Fails when the process exits immediately.
pub fn spawn_background(
+391 -28
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};
@@ -56,6 +56,8 @@ pub struct AmCompleter {
groups: Vec<String>,
/// 'profile' subcommands.
profile_sub: Vec<&'static str>,
/// 'catalog' subcommands (issues #60 #67).
catalog_sub: Vec<&'static str>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
@@ -103,9 +105,24 @@ 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"),
("models", "list local models (ollama, llama.cpp, LM Studio)"),
("catalog", "manage remote catalogs"),
("suggest", "recommend an agent for a request"),
("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"),
("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"),
("sync", "push the state (journal, sessions, config) into a git repo"),
("migrate", "export/import a machine transfer bundle (config + state)"),
("playbook", "replay a saved history sequence step by step (am history --save)"),
("lab", "benchmark the same task on several agents (duration, cost, --json)"),
("web", "local read-only web dashboard with charts (issue #54)"),
("plugins", "list the event plugins and test one (JSON contract on stdin/stdout)"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("lang", "show or switch the output language (fr/en)"),
("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 +160,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
}
@@ -190,13 +231,15 @@ impl AmCompleter {
commands: vec![
"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",
"profile", "man",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "suggest", "audit",
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
profile_sub: vec!["list", "show"],
catalog_sub: vec!["update", "add", "list"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
gateway_shells: vec![
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
@@ -206,7 +249,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,
@@ -240,6 +283,9 @@ impl AmCompleter {
"profile" => {
pool = self.profile_sub.iter().map(|s| s.to_string()).collect();
}
"catalog" => {
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
@@ -252,6 +298,9 @@ impl AmCompleter {
.map(|s| s.to_string())
.collect();
}
"lang" => {
pool = vec!["fr".to_string(), "en".to_string()];
}
"cd" | "chdir" => {
// Complete directories only — dive one level per Tab.
let cwd = current_dir();
@@ -593,6 +642,7 @@ pub fn banner_box(
session: &ShellSession,
session_id: &str,
cwd: &str,
lang: &str,
theme: &Theme,
color: bool,
) -> String {
@@ -711,32 +761,54 @@ 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 · web · 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(
" models models · models --prune · catalog · suggest · audit".to_string(),
));
rows.push(inner(
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
));
rows.push(inner(
" bench lab --agents a,b --task t.yaml · plugins · plugins --test <name>".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 · lang · help · version · exit"
.to_string(),
));
// Stats footer.
rows.push(blank());
rows.push(inner(dim(
&format!(
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · /help for commands",
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · lang {} · /help for commands",
session.current.name,
theme.name
theme.name,
lang,
),
theme,
color,
@@ -766,6 +838,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
session,
session_id,
&current_dir_string(),
app.lang().code(),
theme,
color,
);
@@ -1027,6 +1100,16 @@ pub fn run(app: &App) -> Result<i32> {
}
};
let _ = crate::sessions::finish_repl(app, &sid);
// Issue #66: optional auto-sync of the state when the REPL exits.
if app.config.settings.sync_on_exit.unwrap_or(false) {
match crate::sync::sync(app, "repl exit — state sync") {
Ok(crate::sync::SyncOutcome::Pushed { .. }) => {
app.log.success("state pushed (sync_on_exit)");
}
Ok(_) => {}
Err(e) => app.log.warn(&format!("sync_on_exit failed: {e:#}")),
}
}
result
}
@@ -1078,10 +1161,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) {
@@ -1175,6 +1263,10 @@ fn handle_input(
theme_command(app, None);
return Ok(false);
}
"lang" | "/lang" => {
lang_command(app, None);
return Ok(false);
}
_ => {}
}
// History expansion: !! !N !prefix ^old^new (re-execution, issue #27).
@@ -1194,6 +1286,7 @@ fn handle_input(
match cmd {
"shell" => shell_command(session, arg),
"theme" => theme_command(app, arg),
"lang" => lang_command(app, arg),
"help" => match arg {
Some(topic) => {
if let Err(e) = crate::help::print_command(app, topic) {
@@ -1204,7 +1297,7 @@ fn handle_input(
},
_ => app
.log
.error(&format!("unknown command '/{cmd}' — type '/help'")),
.error(crate::tr_fmt!("unknown command '/{}' — type '/help'", cmd)),
}
return Ok(false);
}
@@ -1274,6 +1367,11 @@ fn handle_line(
theme_command(app, rest.first().map(|s| s.as_str()));
return Ok(false);
}
// lang | lang <fr|en>: show the output language or switch it (issue #74).
if cmd == "lang" {
lang_command(app, rest.first().map(|s| s.as_str()));
return Ok(false);
}
// Session directory change: kept in the am process, not a child shell.
if matches!(cmd, "cd" | "chdir") {
@@ -1370,10 +1468,36 @@ fn handle_line(
}),
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
_ => {
app.log.error("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list");
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list"));
return Ok(false);
}
},
"models" => Command::Models(crate::cli::ModelsArgs {
prune: flag("--prune"),
dry_run: flag("--dry-run"),
yes: flag("--yes") || app.yes(),
days: opt_value("--days").and_then(|v| v.parse().ok()),
}),
"catalog" => match rest.first().map(|s| s.as_str()) {
Some("update") | None => Command::Catalog(crate::cli::CatalogCmd::Update),
Some("add") if rest.len() >= 2 => Command::Catalog(crate::cli::CatalogCmd::Add {
url: rest[1].clone(),
}),
Some("list") => Command::Catalog(crate::cli::CatalogCmd::List),
_ => {
app.log.error(crate::i18n::tr("usage: catalog update | catalog add <url> | catalog list"));
return Ok(false);
}
},
"suggest" => {
if rest.is_empty() {
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
return Ok(false);
}
Command::Suggest {
words: rest.to_vec(),
}
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
@@ -1384,7 +1508,7 @@ fn handle_line(
}),
Some("list") | None => Command::Alias(crate::cli::AliasCmd::List),
_ => {
app.log.error("usage: alias add <name> <target> | alias remove <name> | alias list");
app.log.error(crate::i18n::tr("usage: alias add <name> <target> | alias remove <name> | alias list"));
return Ok(false);
}
},
@@ -1419,7 +1543,8 @@ fn handle_line(
Some("list") | None => Command::Profile(crate::cli::ProfileCmd::List),
other => {
app.log.error(&format!(
"usage: profile list | profile show <name>{}",
"{}{}",
crate::i18n::tr("usage: profile list | profile show <name>"),
other.map(|o| format!(" (unknown 'profile {o}')")).unwrap_or_default()
));
return Ok(false);
@@ -1438,10 +1563,40 @@ fn handle_line(
status: opt_value("--status"),
show: opt_value("--show"),
resume: opt_value("--resume"),
export: opt_value("--export"),
output: opt_value("--output"),
retention: opt_value("--retention").and_then(|v| v.parse().ok()),
},
"stats" => Command::Stats {
agent: rest.first().cloned(),
period: opt_value("--period"),
costs: flag("--costs"),
},
"monitor" => Command::Monitor {
interval: opt_value("--interval").and_then(|v| v.parse().ok()),
json: flag("--json"),
},
"web" => Command::Web(crate::cli::WebArgs {
port: opt_value("--port").and_then(|v| v.parse().ok()),
no_open: flag("--no-open"),
}),
"sync" => Command::Sync {
message: opt_value("--message"),
},
"migrate" => {
if flag("--export") {
Command::Migrate {
export: true,
output: opt_value("--output").map(std::path::PathBuf::from),
bundle: None,
}
} else {
Command::Migrate {
export: false,
output: None,
bundle: rest.first().map(std::path::PathBuf::from),
}
}
},
"top" => Command::Top {
period: opt_value("--period"),
@@ -1471,12 +1626,39 @@ fn handle_line(
kind: opt_value("--kind"),
cwd: opt_value("--cwd"),
search: opt_value("--search"),
failed: flag("--failed"),
session: opt_value("--session"),
failed: flag("--failed"), session: opt_value("--session"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()),
range: rest.first().cloned(),
save: opt_value("--save").map(std::path::PathBuf::from),
},
"playbook" => {
if rest.is_empty() {
app.log.error(crate::i18n::tr("usage: playbook <file> — ex: playbook deploy.yaml"));
return Ok(false);
}
Command::Playbook {
path: std::path::PathBuf::from(rest[0].clone()),
var: rest
.iter()
.filter_map(|t| t.strip_prefix("--var=").map(|v| v.to_string()))
.collect(),
}
},
"lab" => Command::Lab(crate::cli::LabArgs {
agents: opt_value("--agents"),
task: opt_value("--task").map(std::path::PathBuf::from),
parallel: flag("--parallel"),
timeout: opt_value("--timeout").and_then(|v| v.parse().ok()),
list: flag("--list"),
}),
"plugins" => Command::Plugins(crate::cli::PluginsArgs {
test: opt_value("--test"),
}),
"init" => Command::Init {
force: flag("--force"),
template: opt_value("--template"),
},
"init" => Command::Init { force: flag("--force") },
"search" => {
let words: Vec<String> = rest
.iter()
@@ -1528,19 +1710,80 @@ fn handle_line(
Command::Update {
agent: None,
all: true,
rollback: opt_value("--rollback"),
}
} else if let Some(rb) = opt_value("--rollback") {
Command::Update {
agent: None,
all: false,
rollback: Some(rb),
}
} else {
Command::Update {
agent: rest.first().cloned(),
all: false,
rollback: None,
}
}
}
"doctor" => Command::Doctor { fix: flag("--fix") },
"audit" => Command::Audit,
"service" => match rest.first().map(|s| s.as_str()) {
Some(action) if rest.len() >= 2 => Command::Service(match action {
"install" => crate::cli::ServiceCmd::Install {
agent: rest[1].clone(),
autostart: flag("--autostart"),
},
"uninstall" => crate::cli::ServiceCmd::Uninstall {
agent: rest[1].clone(),
},
"status" => crate::cli::ServiceCmd::Status {
agent: rest[1].clone(),
},
_ => {
app.log
.error("usage: service install|uninstall|status <agent> [--autostart]");
return Ok(false);
}
}),
_ => {
app.log
.error("usage: service install|uninstall|status <agent> [--autostart]");
return Ok(false);
}
},
"schedule" => match rest.first().map(|s| s.as_str()) {
Some("add") => Command::Schedule(crate::cli::ScheduleCmd::Add {
command: rest[1..]
.iter()
.filter(|a| !a.starts_with("--"))
.cloned()
.collect(),
at: opt_value("--at"),
}),
Some("list") | None => Command::Schedule(crate::cli::ScheduleCmd::List),
Some("remove") if rest.len() >= 2 => Command::Schedule(
crate::cli::ScheduleCmd::Remove {
id: rest[1].clone(),
},
),
Some("run") if rest.len() >= 2 => Command::Schedule(crate::cli::ScheduleCmd::Run {
id: rest[1].clone(),
}),
_ => {
app.log.error(crate::i18n::tr("usage: schedule add <cmd...> | schedule list | schedule remove <id>"));
return Ok(false);
}
},
"doctor" => Command::Doctor {
fix: flag("--fix"),
watch: opt_value("--watch").and_then(|v| v.parse().ok()),
},
"run" => {
let agent = need("agent name")?;
Command::Run {
agent,
model: opt_value("--model"),
container: flag("--container"),
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
}
@@ -1568,9 +1811,12 @@ 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'"));
.error(crate::tr_fmt!("unknown command '{}' — type 'help'", other));
return Ok(false);
}
};
@@ -1585,13 +1831,23 @@ fn handle_line(
fn is_shell_command(word: &str) -> bool {
matches!(
word,
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "help" | "version" | "exit"
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "lang" | "help" | "version" | "exit"
)
}
/// 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 {
pub fn is_am_command(word: &str) -> bool {
matches!(
word,
"list"
@@ -1635,6 +1891,20 @@ fn is_am_command(word: &str) -> bool {
| "tags"
| "profile"
| "man"
| "tip"
| "models"
| "catalog"
| "suggest"
| "audit"
| "service"
| "schedule"
| "monitor"
| "web"
| "sync"
| "migrate"
| "playbook"
| "lab"
| "plugins"
)
}
@@ -1786,7 +2056,7 @@ fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
let code = session.run(line)?;
if code != 0 {
app.log
.error(&format!("command exited with code {code}"));
.error(crate::tr_fmt!("command exited with code {}", code));
}
app.emit(
&Event::now(EventKind::Shell)
@@ -1884,6 +2154,48 @@ fn print_theme_status(app: &App) {
);
}
/// Show or switch the output language (issue #74). With no argument the
/// current locale is shown; with 'fr' or 'en' it is switched for the rest
/// of the session (the i18n global follows immediately).
fn lang_command(app: &App, arg: Option<&str>) {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => print_lang_status(app),
Some(code) => match code.to_ascii_lowercase().as_str() {
"fr" | "french" | "français" => {
app.set_lang(crate::i18n::Lang::Fr);
println!("language: {}", crate::i18n::Lang::Fr.label());
}
"en" | "english" | "anglais" => {
app.set_lang(crate::i18n::Lang::En);
println!("language: {}", crate::i18n::Lang::En.label());
}
other => app.log.error(format!(
"unknown language '{other}' — use 'lang fr' or 'lang en'"
)),
},
}
}
/// Show the active language and the switch hint.
fn print_lang_status(app: &App) {
let lang = app.lang();
let color = app.color();
let theme = app.theme();
let hdr = |s: &str| -> String {
if color {
theme.hdr(s)
} else {
s.to_string()
}
};
println!("{}", hdr("Language"));
println!(" ◉ {} ({})", theme.acc(lang.label()), lang.code());
println!(
" {}",
theme.dimmed("'lang <fr|en>' to switch (this session) · 'am --lang <fr|en>' at startup")
);
}
fn print_shell_status(session: &ShellSession) {
let others: Vec<&str> = session
.available
@@ -1990,6 +2302,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();
@@ -2029,6 +2362,7 @@ mod tests {
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
"fr",
crate::theme::default_theme(),
false,
);
@@ -2055,6 +2389,33 @@ 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",
"fr",
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();
@@ -2067,6 +2428,7 @@ mod tests {
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
"fr",
ocean,
true,
);
@@ -2170,6 +2532,7 @@ mod tests {
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
"fr",
ocean,
true,
);
+183
View File
@@ -29,6 +29,14 @@ pub struct SessionRecord {
/// RFC 3339 end timestamp (None while running).
#[serde(default)]
pub ended_at: Option<String>,
/// Tokens billed once the agent exposed its usage (issue #49).
#[serde(default)]
pub tokens_in: Option<u64>,
#[serde(default)]
pub tokens_out: Option<u64>,
/// Estimated cost in USD (issue #49).
#[serde(default)]
pub cost_usd: Option<f64>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
@@ -125,6 +133,9 @@ pub fn start_agent(
project,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
tokens_in: None,
tokens_out: None,
cost_usd: None,
exit_code: None,
log,
args,
@@ -151,6 +162,9 @@ pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
project: None,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
tokens_in: None,
tokens_out: None,
cost_usd: None,
exit_code: None,
log: None,
args: Vec::new(),
@@ -184,12 +198,15 @@ pub fn finish_agent(app: &App, agent: &str, pid: Option<u32>, exit_code: i32, ok
candidates.first().cloned()
};
if let Some(k) = key {
let cwd = sf.sessions.get(&k).and_then(|r| r.cwd.clone());
if let Some(r) = sf.sessions.get_mut(&k) {
r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(exit_code);
r.status = if ok { "stopped" } else { "failed" }.to_string();
}
save(app, &sf)?;
// Issue #49: collect the token usage the agent exposed and price it.
let _ = crate::costs::record_session_usage(app, agent, &k, cwd.as_deref());
}
Ok(())
}
@@ -247,6 +264,130 @@ pub fn duration_s(r: &SessionRecord) -> Option<u64> {
Some(secs.max(0) as u64)
}
/// Default retention: sessions older than this many days are purged by
/// 'am sessions --retention' / startup (issue #53).
pub const DEFAULT_RETENTION_DAYS: u64 = 90;
/// Complete, self-contained export of one session (issue #53): metadata,
/// the recorded commands and a log excerpt. Round-trips through
/// 'am import' / archiving; the primary event journal is never touched.
#[derive(Debug, Clone, Serialize)]
pub struct SessionExport {
pub format: &'static str,
pub format_version: u32,
pub exported_at: String,
pub session: SessionRecord,
/// Commands recorded by the REPL during this session (history/<id>.jsonl).
pub commands: Vec<crate::history::HistoryEntry>,
/// Tail of the agent log file (last ~4 KB), if any.
pub log_excerpt: String,
pub log_path: Option<String>,
/// Human-readable rendering, for manual archiving.
pub readable: String,
}
/// Export one session (issue #53). Fails with a clear message when the id
/// is unknown.
pub fn export_session(app: &App, id: &str) -> Result<SessionExport> {
let sf = load(app)?;
let session = sf
.sessions
.get(id)
.cloned()
.ok_or_else(|| anyhow!("unknown session '{id}' — use 'am sessions' to list them"))?;
// Commands recorded by the REPL during this session.
let commands = crate::history::read_session(app, id);
// Log excerpt: last ~4 KB of the agent log file.
let (log_excerpt, log_path) = match &session.log {
Some(p) if PathBuf::from(p).is_file() => {
let text = std::fs::read_to_string(p).unwrap_or_default();
let excerpt = text.chars().rev().take(4096).collect::<String>().chars().rev().collect();
(excerpt, Some(p.clone()))
}
other => (String::new(), other.clone()),
};
let mut readable = format!(
"Session {}\n kind={} status={} agent={}\n started={} ended={}\n",
session.id,
session.kind,
session.status,
session.agent.as_deref().unwrap_or("-"),
session.started_at,
session.ended_at.as_deref().unwrap_or("-"),
);
if let Some(p) = &session.project {
readable.push_str(&format!(" project={p}\n"));
}
if let Some(c) = session.exit_code {
readable.push_str(&format!(" exit_code={c}\n"));
}
readable.push_str(&format!(
" duration={}s\n",
duration_s(&session).unwrap_or(0)
));
if !commands.is_empty() {
readable.push_str(&format!("\ncommands ({}):\n", commands.len()));
for c in commands.iter().rev().take(25) {
readable.push_str(&format!(" {}\n", c.cmd));
}
}
if !log_excerpt.is_empty() {
readable.push_str(&format!("\nlog excerpt:\n{log_excerpt}\n"));
}
Ok(SessionExport {
format: "am-session-export",
format_version: 1,
exported_at: crate::installers::now_rfc3339(),
session,
commands,
log_excerpt,
log_path,
readable,
})
}
/// Purge sessions finished more than `retention_days` ago (issue #53).
/// Only the derived sessions.json index is pruned — the primary event
/// journal is untouched. Purged ids are journaled as EventKind::Prune so
/// 'am audit' can explain the disappearance.
pub fn purge_expired(app: &App, retention_days: u64) -> Result<usize> {
let mut sf = load(app)?;
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
let mut purged: Vec<String> = Vec::new();
sf.sessions.retain(|id, r| {
if r.status == "running" {
return true; // never purge a live session
}
let end = r
.ended_at
.as_deref()
.or(Some(r.started_at.as_str()))
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| t.with_timezone(&chrono::Utc));
let keep = match end {
Some(e) => e >= cutoff,
None => true, // undatable session: keep it
};
if !keep {
purged.push(id.clone());
}
keep
});
if !purged.is_empty() {
save(app, &sf)?;
let _ = app.emit(
&crate::events::Event::now(crate::events::EventKind::Prune)
.with_reason("sessions-retention")
.with_args(vec![
format!("{retention_days}d"),
purged.len().to_string(),
purged.join(","),
]),
);
}
Ok(purged.len())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -336,4 +477,46 @@ mod tests {
assert_eq!(stopped[0].exit_code, Some(1));
assert_eq!(stopped[0].status, "failed");
}
#[test]
fn export_is_complete_and_reproducible() {
let app = test_app("export");
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], Some("/nonexistent.log".into())).unwrap();
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let ex = export_session(&app, &id).unwrap();
assert_eq!(ex.format, "am-session-export");
assert_eq!(ex.session.id, id);
assert!(ex.readable.contains("claude-code"), "readable rendering");
// Unknown id fails clearly.
assert!(export_session(&app, "nope").is_err());
}
#[test]
fn purge_removes_only_finished_old_sessions() {
let app = test_app("purge");
// Finished 200 days ago: candidate.
let id_old = start_agent(&app, "a", 1, None, None, vec![], vec![], None).unwrap();
finish_agent(&app, "a", Some(1), 0, true).unwrap();
// Finished yesterday: kept.
let id_recent = start_agent(&app, "b", 2, None, None, vec![], vec![], None).unwrap();
finish_agent(&app, "b", Some(2), 0, true).unwrap();
// Still running: never purged even if old.
let id_running = start_agent(&app, "c", 3, None, None, vec![], vec![], None).unwrap();
// Rewind the dates in one load-modify-save pass.
let mut sf = load(&app).unwrap();
let past = (chrono::Utc::now() - chrono::Duration::days(200)).to_rfc3339();
sf.sessions.get_mut(&id_old).unwrap().ended_at = Some(past.clone());
sf.sessions.get_mut(&id_recent).unwrap().ended_at =
Some((chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339());
sf.sessions.get_mut(&id_running).unwrap().started_at = past;
save(&app, &sf).unwrap();
let purged = purge_expired(&app, 90).unwrap();
assert_eq!(purged, 1, "only the 200-day-old finished session");
let sf = load(&app).unwrap();
assert!(!sf.sessions.contains_key(&id_old));
assert!(sf.sessions.contains_key(&id_recent));
assert!(sf.sessions.contains_key(&id_running));
}
}
+42
View File
@@ -24,6 +24,46 @@ pub struct StateFile {
/// Personal annotations per agent: favorite, note, tags (issue #39).
#[serde(default)]
pub annotations: BTreeMap<String, Annotation>,
/// Config file checksums used by 'am audit' (axe 8, issue #63).
#[serde(default)]
pub config_checksums: BTreeMap<String, ConfigChecksum>,
/// Scheduled commands (axe 6, issue #56).
#[serde(default)]
pub schedules: Vec<ScheduleEntry>,
}
/// One scheduled command (issue #56). Stored in state.json; the OS-level
/// task (crontab / Task Scheduler) points back at 'am schedule-run <id>'.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScheduleEntry {
pub id: String,
/// "daily" (the only cadence supported today).
pub cadence: String,
/// Command arguments to run, e.g. ["update", "--all"].
pub command: Vec<String>,
/// HH:MM local time (default "08:00").
#[serde(default)]
pub at: String,
/// RFC 3339 creation timestamp.
pub created_at: String,
/// RFC 3339 of the last run (None = never).
#[serde(default)]
pub last_run: Option<String>,
/// Exit code of the last run (None = never ran / running).
#[serde(default)]
pub last_exit: Option<i32>,
}
/// One recorded config checksum: the baseline 'am audit' compares against
/// to detect manual modifications (issue #63).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigChecksum {
/// SHA-256 of the file content (hex).
pub sha256: String,
/// File modification time (seconds since epoch) at recording time.
pub mtime_secs: u64,
/// RFC 3339 timestamp of the recording.
pub recorded_at: String,
}
/// Personal annotations attached to an agent by the user (issue #39).
@@ -48,6 +88,8 @@ impl Default for StateFile {
sessions_count: 0,
last_used: None,
annotations: BTreeMap::new(),
config_checksums: BTreeMap::new(),
schedules: Vec::new(),
}
}
}
+269
View File
@@ -0,0 +1,269 @@
//! Synchronization of the runtime state into a git repository (issue #66).
//! `am sync` commits and pushes the state directory (state.json, event
//! journal, history) to settings.sync_repo. Sensitive data is excluded via
//! a managed .gitignore (logs, backups, token/key/env files). Conflicts are
//! resolved "last writer wins" with a local backup of the pre-push state.
use anyhow::{bail, Context, Result};
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Outcome of a sync run.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum SyncOutcome {
/// No settings.sync_repo configured.
Skipped,
/// Nothing to commit (working tree clean).
Clean,
/// Committed and pushed.
Pushed { files: u32, message: String },
}
/// Content of the managed .gitignore (additive; the user's own entries are
/// preserved). Never expose secrets, logs or backup payloads.
pub const MANAGED_GITIGNORE: &str = r#"# managed by agent-manager (am sync — issue #66)
logs/
backups/
*.token
*.key
*.pem
.env
.env.*
secrets*.json
id_rsa*
"#;
/// Run a git command inside `dir`; returns stdout on success.
pub fn git(dir: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.with_context(|| format!("cannot run git in {}", dir.display()))?;
if !out.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// Ensure the managed ignore rules are present (merged, never overwriting
/// the user's own rules).
pub fn ensure_gitignore(dir: &Path) -> Result<()> {
let path = dir.join(".gitignore");
let mut existing = String::new();
if path.exists() {
existing = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
}
let mut out = existing.clone();
if !existing.contains("# managed by agent-manager") {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(MANAGED_GITIGNORE);
}
if out != existing {
std::fs::write(&path, out)
.with_context(|| format!("cannot write {}", path.display()))?;
}
Ok(())
}
/// Count the files staged for the next commit (git status --porcelain).
fn staged_count(dir: &Path) -> u32 {
git(dir, &["status", "--porcelain"])
.map(|s| s.lines().filter(|l| !l.is_empty()).count() as u32)
.unwrap_or(0)
}
/// Init the repository when needed and wire the configured remote.
fn ensure_repo(dir: &Path, repo: &str) -> Result<()> {
if !dir.join(".git").exists() {
git(dir, &["init", "-q"])?;
git(dir, &["config", "user.name", "agent-manager"])?;
git(dir, &["config", "user.email", "agent-manager@local"])?;
}
let remotes = git(dir, &["remote"]).unwrap_or_default();
if !remotes.lines().any(|r| r == "origin") {
git(dir, &["remote", "add", "origin", repo])?;
} else {
let url = git(dir, &["remote", "get-url", "origin"]).unwrap_or_default();
if url != repo {
git(dir, &["remote", "set-url", "origin", repo])?;
}
}
Ok(())
}
/// Back up the current state locally before a forced push (conflict
/// resolution: last writer wins, nothing lost).
fn backup_before_force(dir: &Path) -> Result<PathBuf> {
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let target = dir.join("backups").join(format!("sync-conflict-{ts}"));
std::fs::create_dir_all(&target)
.with_context(|| format!("cannot create {}", target.display()))?;
for name in ["state.json"] {
let src = dir.join(name);
if src.exists() {
std::fs::copy(&src, target.join(name))?;
}
}
// Also snapshot the current journal file.
let events = dir.join("events");
if let Ok(entries) = std::fs::read_dir(&events) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
std::fs::copy(&p, target.join(format!("events-{}", p.file_name().unwrap_or_default().to_string_lossy())))?;
}
}
}
Ok(target)
}
/// Commit and push the state directory. Returns the outcome.
pub fn sync(app: &crate::app::App, message: &str) -> Result<SyncOutcome> {
let Some(repo) = app.config.settings.sync_repo.clone() else {
return Ok(SyncOutcome::Skipped);
};
let dir = app.events_dir();
std::fs::create_dir_all(&dir)?;
ensure_gitignore(&dir)?;
ensure_repo(&dir, &repo)?;
git(&dir, &["add", "-A"])?;
let staged = staged_count(&dir);
if staged == 0 {
return Ok(SyncOutcome::Clean);
}
git(&dir, &["commit", "-q", "-m", message])?;
let push = git(&dir, &["push", "-u", "origin", "HEAD"]);
match push {
Ok(_) => Ok(SyncOutcome::Pushed {
files: staged,
message: message.to_string(),
}),
Err(_) => {
// Non-fast-forward (or remote behind): last writer wins, with a
// local backup of the state before the forced push.
let backup = backup_before_force(&dir)?;
git(&dir, &["push", "--force", "origin", "HEAD"])?;
eprintln!(
"conflict resolved (last writer wins) — pre-push state backed up in {}",
backup.display()
);
Ok(SyncOutcome::Pushed {
files: staged,
message: message.to_string(),
})
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn gitignore_managed_lines_are_merged_once() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".gitignore");
std::fs::write(&p, "custom-rule\n").unwrap();
ensure_gitignore(dir.path()).unwrap();
let text = std::fs::read_to_string(&p).unwrap();
assert!(text.contains("custom-rule"));
assert!(text.contains("logs/"));
assert!(text.contains("*.token"));
// Idempotent: running again does not duplicate the block.
ensure_gitignore(dir.path()).unwrap();
let again = std::fs::read_to_string(&p).unwrap();
assert_eq!(again.matches("# managed by agent-manager").count(), 1);
}
#[test]
fn sync_skips_without_repo_setting() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = crate::app::App::from_cli(cli).unwrap();
app.paths.state_file = dir.path().join("state/state.json");
let out = sync(&app, "test").unwrap();
assert_eq!(out, SyncOutcome::Skipped);
}
#[test]
fn sync_pushes_to_local_remote() {
if !git_available() {
eprintln!("git not available — skipping");
return;
}
let dir = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let remote_dir = remote.path().join("repo.git");
git(remote.path(), &["init", "-q", "--bare", remote_dir.to_str().unwrap()]).unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n sync_repo: \"file://REPLACE\"\nagents: []\n",
)
.unwrap();
// Patch the repo URL into the config text (forward slashes keep the
// YAML quoted scalar valid on Windows paths).
let text = std::fs::read_to_string(&cfg).unwrap().replace(
"file://REPLACE",
&format!("file://{}", remote_dir.to_string_lossy().replace('\\', "/")),
);
std::fs::write(&cfg, text).unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = crate::app::App::from_cli(cli).unwrap();
let state_dir = dir.path().join("state");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("state.json"), "{\"version\":2}").unwrap();
std::fs::write(state_dir.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap();
app.paths.state_file = state_dir.join("state.json");
let out = sync(&app, "test push").unwrap();
match out {
SyncOutcome::Pushed { files, .. } => assert!(files > 0),
other => panic!("expected Pushed, got {other:?}"),
}
// The remote really received the commit.
let log = git(&remote_dir, &["log", "--oneline", "-1"]).unwrap();
assert!(log.contains("test push"));
}
#[test]
fn backup_before_force_snapshots_state() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("state")).unwrap();
std::fs::write(dir.path().join("state/state.json"), "{\"v\":1}").unwrap();
let backup = backup_before_force(&dir.path().join("state")).unwrap();
assert!(backup.join("state.json").exists());
assert!(backup.to_string_lossy().contains("sync-conflict"));
}
}
+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]
+138
View File
@@ -0,0 +1,138 @@
//! Embedded project templates for 'am init --template' (issue #52).
//! Each template renders an agent-manager.yaml pre-filled for a stack:
//! a dev group, a default profile and project hooks. Variables {project}
//! and {dir} are substituted at render time.
use anyhow::{bail, Result};
/// (name, description) of every available template.
pub const TEMPLATES: &[(&str, &str)] = &[
("web", "frontend/web full-stack : node, npm, tests, serveur local"),
("python", "python : uv/pip, pytest, ruff, mypy"),
("rust", "rust : cargo, clippy, tests, rustfmt"),
("cli", "générique CLI : commandes shell, scripts, git"),
];
/// The four embedded templates.
const WEB: &str = r#"# Généré par 'am init --template web' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, opencode, gemini-cli]
profiles:
dev:
agent: claude-code
env:
NODE_ENV: development
CI: "1"
args: []
settings:
hooks:
on_install: ["npm install"]
on_start: ["npm run dev"]
agents: []
"#;
const PYTHON: &str = r#"# Généré par 'am init --template python' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, codex, aider]
profiles:
dev:
agent: claude-code
env:
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
args: []
settings:
hooks:
on_install: ["uv sync --all-extras"]
on_start: ["uv run pytest -q"]
agents: []
"#;
const RUST: &str = r#"# Généré par 'am init --template rust' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, codex, jcode]
profiles:
dev:
agent: claude-code
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: "1"
args: []
settings:
hooks:
on_install: ["cargo fetch"]
on_start: ["cargo test"]
agents: []
"#;
const CLI: &str = r#"# Généré par 'am init --template cli' — projet {project}
# Répertoire : {dir}
version: "1.0"
groups:
dev: [claude-code, opencode]
profiles:
dev:
agent: claude-code
env: {}
args: []
agents: []
"#;
fn template_of(name: &str) -> Option<&'static str> {
match name {
"web" => Some(WEB),
"python" => Some(PYTHON),
"rust" => Some(RUST),
"cli" => Some(CLI),
_ => None,
}
}
/// Render a template for a project. `project` is the project name (used in
/// the header comment), `dir` the absolute directory path. Unknown template
/// names fail with the available list.
pub fn render(name: &str, project: &str, dir: &str) -> Result<String> {
let Some(tpl) = template_of(name) else {
let list = TEMPLATES
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(", ");
bail!("unknown template '{name}' — available: {list}");
};
Ok(tpl.replace("{project}", project).replace("{dir}", dir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_all_templates_with_substitution() {
for (name, _) in TEMPLATES {
let out = render(name, "mon-app", "/work/mon-app").unwrap();
assert!(out.contains("mon-app"), "{name}: project substituted");
assert!(out.contains("/work/mon-app"), "{name}: dir substituted");
assert!(!out.contains("{project}"), "{name}: no leftover var");
assert!(!out.contains("{dir}"), "{name}: no leftover var");
// The rendered config must parse and validate.
let cfg: crate::config::Config = serde_yaml::from_str(&out).unwrap_or_else(|e| {
panic!("{name}: invalid YAML: {e}\n{out}")
});
assert!(crate::config::validate(&cfg).is_empty(), "{name}: invalid config");
assert!(!cfg.groups.is_empty(), "{name}: a dev group is expected");
}
}
#[test]
fn unknown_template_fails_with_list() {
let err = render("cobol", "x", "/tmp").unwrap_err();
assert!(err.to_string().contains("web"), "{err}");
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Local web dashboard (issue #54): a read-only HTTP server bound to
//! 127.0.0.1, serving the embedded frontend and a JSON API that mirrors
//! the CLI's stable `--json` contracts (same serde structs).
//!
//! Security model: localhost bind only, GET-only routes, no mutation
//! endpoints. Remote access with a token is issue #80 (phase 3).
//!
//! The frontend is a single self-contained HTML file embedded at compile
//! time (`include_str!`): no external assets, no CDN, works offline.
use crate::app::App;
use anyhow::{Context, Result};
use serde::Serialize;
use tiny_http::{Header, Method, Response, Server, StatusCode};
/// Default listening port (overridable with `am web --port`).
pub const DEFAULT_PORT: u16 = 7878;
/// The embedded single-file frontend (HTML + CSS + JS, dark cockpit).
const INDEX_HTML: &str = include_str!("frontend/index.html");
/// One HTTP exchange result, decoupled from tiny_http so the routing
/// logic stays a pure function (unit-testable without sockets).
pub struct HttpResult {
pub status: u16,
pub content_type: &'static str,
pub body: Vec<u8>,
}
impl HttpResult {
fn json<T: Serialize>(value: &T) -> Self {
let body = serde_json::to_vec(value)
.unwrap_or_else(|_| b"{\"error\":\"serialization failed\"}".to_vec());
Self {
status: 200,
content_type: "application/json; charset=utf-8",
body,
}
}
/// Serialize a `Result`: Ok → 200 JSON, Err → 500 JSON with the error.
fn api<T: Serialize>(result: Result<T>) -> Self {
match result {
Ok(v) => Self::json(&v),
Err(e) => Self {
status: 500,
content_type: "application/json; charset=utf-8",
body: serde_json::json!({ "error": format!("{e:#}") })
.to_string()
.into_bytes(),
},
}
}
fn text(status: u16, content_type: &'static str, body: String) -> Self {
Self {
status,
content_type,
body: body.into_bytes(),
}
}
}
/// Route one request. Pure function: the only I/O is the app's own data
/// files (journal, sessions, state) — never writes anything.
pub fn handle(app: &App, method: &Method, target: &str) -> HttpResult {
if *method != Method::Get {
return HttpResult::text(
405,
"text/plain; charset=utf-8",
"method not allowed — this API is read-only".to_string(),
);
}
let (path, params) = split_query(target);
match path {
"/" | "/index.html" => index_page(app),
"/api/health" => HttpResult::json(&serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
})),
// Same shape as `am dashboard --json`.
"/api/overview" => HttpResult::json(&crate::dashboard::collect(app)),
// Same shape as `am stats --json` (optional ?agent=&period=).
"/api/stats" => HttpResult::api(crate::commands::stats_cmd::aggregate(
app,
query_get(&params, "agent").as_deref(),
query_get(&params, "period").as_deref(),
)),
// Same shape as `am sessions --json` (optional ?agent=&project=&status=).
"/api/sessions" => HttpResult::api(crate::commands::sessions_cmd::list_rows(
app,
query_get(&params, "agent").as_deref(),
query_get(&params, "project").as_deref(),
query_get(&params, "status").as_deref(),
)),
// Same shape as `am log --json` (optional ?agent=&kind=&since=&limit=).
"/api/events" => {
let limit = query_get(&params, "limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(200)
.min(5000);
HttpResult::api(crate::commands::log_cmd::rows(
app,
query_get(&params, "agent").as_deref(),
query_get(&params, "kind").as_deref(),
query_get(&params, "since").as_deref(),
limit,
))
}
// Same shape as `am projects --json`.
"/api/projects" => HttpResult::json(&crate::projects::aggregate(app)),
_ => HttpResult::text(
404,
"text/plain; charset=utf-8",
format!("not found: {path}"),
),
}
}
/// The embedded page, with every `{{label}}` token replaced by the
/// localized label (`tr_in`), and the status-label bundle injected for
/// the client-side rendering.
fn index_page(app: &App) -> HttpResult {
let lang = app.lang();
// The status-label bundle first: it contains raw JSON (quotes, braces)
// that the {{...}} loop below must not touch.
let bundle = serde_json::json!({
"running": crate::i18n::tr_in(lang, "En cours"),
"stopped": crate::i18n::tr_in(lang, "Terminée"),
"failed": crate::i18n::tr_in(lang, "Échec"),
"interrupted": crate::i18n::tr_in(lang, "Interrompue"),
})
.to_string();
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
// Then every {{label}} token: replaced by the localized label (the
// French text is both the key and the fallback, so nothing survives).
while let Some(start) = html.find("{{") {
let Some(rel_end) = html[start + 2..].find("}}") else {
break;
};
let end = start + 2 + rel_end;
let key = html[start + 2..end].to_string();
let translated = crate::i18n::tr_in(lang, &key);
html.replace_range(start..end + 2, translated);
}
HttpResult {
status: 200,
content_type: "text/html; charset=utf-8",
body: html.into_bytes(),
}
}
/// Serve the dashboard until the process is interrupted (Ctrl-C).
pub fn run(app: &App, port: u16, open_browser: bool) -> Result<i32> {
let addr = format!("127.0.0.1:{port}");
let server = Server::http(&addr)
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
let url = format!("http://{addr}/");
println!(
"{}",
crate::tr_fmt!("am web — {} (Ctrl-C pour arrêter)", url)
);
if open_browser {
println!("{}", crate::i18n::tr("ouverture du navigateur…"));
open_in_browser(&url);
}
for request in server.incoming_requests() {
let method = request.method().clone();
let url = request.url().to_string();
let result = handle(app, &method, &url);
let response = Response::from_data(result.body)
.with_status_code(StatusCode(result.status))
.with_header(
Header::from_bytes(b"Content-Type", result.content_type.as_bytes())
.expect("valid content-type"),
)
.with_header(Header::from_bytes(b"Cache-Control", b"no-store").expect("valid header"));
let _ = request.respond(response);
}
Ok(0)
}
/// Open the dashboard in the default browser (best effort, per platform).
pub fn open_in_browser(url: &str) {
#[cfg(target_os = "windows")]
{
// 'start' is a cmd builtin; the empty quoted argument protects
// URLs containing '&'.
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn();
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(url).spawn();
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
}
// ---------------------------------------------------------------------------
// Query parsing (tiny, standard library only)
// ---------------------------------------------------------------------------
/// Split `path?query` into the path and the decoded query parameters.
fn split_query(target: &str) -> (&str, Vec<(String, String)>) {
match target.split_once('?') {
Some((p, q)) => (p, parse_query(q)),
None => (target, Vec::new()),
}
}
fn parse_query(query: &str) -> Vec<(String, String)> {
query
.split('&')
.filter_map(|kv| {
let (k, v) = kv.split_once('=')?;
Some((percent_decode(k), percent_decode(v)))
})
.collect()
}
fn query_get(params: &[(String, String)], key: &str) -> Option<String> {
params
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
}
/// Decode `+` as space and `%XX` as a byte (best effort; unknown escapes
/// are kept verbatim).
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => {
let hex = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%');
out.push(hex);
i += 2;
}
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn test_app() -> App {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
app.state = crate::state::StateStore::new(dir.join("state.json"));
app
}
#[test]
fn percent_decode_handles_plus_and_hex() {
assert_eq!(percent_decode("a+b%20c%41"), "a b cA");
assert_eq!(percent_decode("plain"), "plain");
assert_eq!(percent_decode("100%"), "100%"); // truncated escape kept
}
#[test]
fn parse_query_splits_and_decodes() {
let (path, params) = split_query("/api/events?agent=claude-code&limit=5&q=a+b");
assert_eq!(path, "/api/events");
assert_eq!(params.len(), 3);
assert_eq!(query_get(&params, "agent").as_deref(), Some("claude-code"));
assert_eq!(query_get(&params, "limit").as_deref(), Some("5"));
assert_eq!(query_get(&params, "q").as_deref(), Some("a b"));
assert_eq!(query_get(&params, "missing"), None);
}
#[test]
fn only_get_is_allowed() {
let app = test_app();
let r = handle(&app, &Method::Post, "/api/stats");
assert_eq!(r.status, 405);
let r = handle(&app, &Method::Get, "/api/stats");
assert_eq!(r.status, 200);
}
#[test]
fn unknown_route_is_404() {
let app = test_app();
let r = handle(&app, &Method::Get, "/api/nope");
assert_eq!(r.status, 404);
}
#[test]
fn health_reports_ok() {
let app = test_app();
let r = handle(&app, &Method::Get, "/api/health");
assert_eq!(r.status, 200);
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
assert_eq!(v["status"], "ok");
assert!(v["version"].is_string());
}
#[test]
fn api_contracts_serialize_as_json_arrays() {
let app = test_app();
for route in [
"/api/overview",
"/api/stats",
"/api/sessions",
"/api/events",
"/api/projects",
] {
let r = handle(&app, &Method::Get, route);
assert_eq!(r.status, 200, "{route}");
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap_or_else(|e| {
panic!("{route}: invalid JSON ({e}): {}", String::from_utf8_lossy(&r.body))
});
assert!(v.is_object() || v.is_array(), "{route} must be JSON data");
}
}
#[test]
fn index_is_served_with_localized_labels() {
let app = test_app();
app.set_lang(crate::i18n::Lang::En);
let r = handle(&app, &Method::Get, "/");
assert_eq!(r.status, 200);
let html = String::from_utf8_lossy(&r.body).into_owned();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Overview"), "EN labels injected");
assert!(!html.contains("{{"), "no leftover tokens: {html}");
assert!(html.contains("window.I18N"), "status bundle injected");
}
}
+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"]);
+1 -1
View File
@@ -11,7 +11,7 @@ fn report_contains_core_checks() {
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(&app, &runner);
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path"] {
for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path", "ollama", "llama-server"] {
assert!(
names.contains(&expected),
"missing check '{expected}' in {names:?}"
+4 -4
View File
@@ -89,7 +89,7 @@ fn command_runs_and_migrates() {
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
").unwrap();
let code = history_cmd::run(&app, None, None, None, false, None, 100, None).unwrap();
let code = history_cmd::run(&app, None, None, None, false, None, 100, None, None, None).unwrap();
assert_eq!(code, 0);
// Migration created the structured file.
assert!(history::dir(&app).join("migrated.jsonl").exists());
@@ -104,12 +104,12 @@ fn rerun_executes_shell_entry_with_yes() {
&HistoryEntry::now("echo rerun-ok").with_kind("shell".to_string()),
)
.unwrap();
let code = history_cmd::run(&app, None, None, None, false, None, 100, Some(1)).unwrap();
let code = history_cmd::run(&app, None, None, None, false, None, 100, Some(1), None, None).unwrap();
assert_eq!(code, 0);
// Numéro hors plage : erreur claire.
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(9)).is_err());
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(9), None, None).is_err());
// Zéro : erreur claire.
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0)).is_err());
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0), None, None).is_err());
}
#[test]
+132
View File
@@ -0,0 +1,132 @@
//! i18n (issue #74): locale selection, English output, stable JSON contracts.
//!
//! The tests spawn the real am binary (CARGO_BIN_EXE_am) so the assertions
//! cover the actual command output, not just the library layer.
mod common;
use agent_manager::i18n::{self, Lang};
use std::path::PathBuf;
use std::process::Command;
/// Run the real am binary with args and a temp config; returns
/// (stdout, stderr, exit code).
fn am(args: &[&str], config: &PathBuf) -> (String, String, i32) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_am"));
cmd.arg("--config").arg(config).args(args);
let out = cmd.output().expect("am binary should run");
(
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
out.status.code().unwrap_or(-1),
)
}
/// A minimal valid config in a fresh temp dir (no user config leakage).
fn temp_config(tag: &str) -> (PathBuf, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"am-i18n-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
(cfg, dir)
}
#[test]
fn lang_flag_selects_english_for_tip_content() {
let (cfg, _dir) = temp_config("flag");
// Explicit French: the tip page is French.
let (fr, _, _) = am(&["--lang", "fr", "tip"], &cfg);
assert!(fr.contains("Lancer & piloter"), "fr tip missing: {fr}");
// --lang en: the same page is translated.
let (en, _, code) = am(&["--lang", "en", "tip"], &cfg);
assert_eq!(code, 0);
assert!(en.contains("Run & control"), "en tip missing: {en}");
assert!(!en.contains("Lancer & piloter"), "untranslated section: {en}");
// The flag works after the subcommand too (global clap flag).
let (en2, _, _) = am(&["tip", "--lang", "en"], &cfg);
assert!(en2.contains("Run & control"), "en tip (post) missing: {en2}");
}
#[test]
fn am_lang_env_selects_english() {
let (cfg, _dir) = temp_config("env");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_am"));
cmd.arg("--config").arg(&cfg).arg("tip");
cmd.env("AM_LANG", "en");
let out = cmd.output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(text.contains("Run & control"), "AM_LANG=en ignored: {text}");
}
#[test]
fn lang_env_values_are_parsed_leniently() {
// The host machine may export LANG/AM_LANG (git-bash sets LANG=en_GB):
// clear them so the default resolution is exercised deterministically.
std::env::remove_var("AM_LANG");
std::env::remove_var("LANG");
std::env::remove_var("LC_ALL");
std::env::remove_var("LC_MESSAGES");
assert_eq!(i18n::resolve(None), Lang::Fr); // no env -> default French
std::env::set_var("AM_LANG", "en_US.UTF-8");
assert_eq!(i18n::resolve(None), Lang::En);
std::env::remove_var("AM_LANG");
// The default follows the host LANG when set (spec: locale selon env).
std::env::set_var("LANG", "fr_FR.UTF-8");
assert_eq!(i18n::resolve(None), Lang::Fr);
}
#[test]
fn app_remembers_the_selected_language() {
let app = common::test_app("applang", &["--lang", "en", "list"]);
assert_eq!(app.lang(), Lang::En);
assert!(i18n::active().is_en());
let app_fr = common::test_app("applangfr", &["--lang", "fr", "list"]);
assert_eq!(app_fr.lang(), Lang::Fr);
assert!(!i18n::active().is_en());
}
#[test]
fn json_contract_is_identical_across_languages() {
// Issue #74 acceptance: --json field names and values are untouched by
// the language switch — only display strings are translated.
let (cfg, _dir) = temp_config("json");
let (fr, _, code_fr) = am(&["list", "--json"], &cfg);
let (en, _, code_en) = am(&["list", "--json", "--lang", "en"], &cfg);
assert_eq!(code_fr, 0);
assert_eq!(code_en, 0);
assert_eq!(fr, en, "list --json differs across languages");
// Same for status --json (structured detail).
let (fr_s, _, _) = am(&["status", "--json"], &cfg);
let (en_s, _, _) = am(&["status", "--json", "--lang", "en"], &cfg);
assert_eq!(fr_s, en_s, "status --json differs across languages");
}
#[test]
fn init_reports_stack_in_english() {
let (cfg, dir) = temp_config("init");
let work = dir.join("proj");
std::fs::create_dir_all(&work).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_am"))
.arg("--config")
.arg(&cfg)
.args(["--lang", "en", "init", "--force"])
.current_dir(&work)
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
text.contains("none detected"),
"EN init message missing: {text}"
);
}
+5 -5
View File
@@ -19,21 +19,21 @@ fn context_detects_stack_from_markers() {
fn generate_writes_valid_config_and_refuses_overwrite() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let path = init_cmd::generate(dir.path(), false).unwrap();
let path = init_cmd::generate(dir.path(), false, None).unwrap();
assert!(path.ends_with("agent-manager.yaml"));
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains(r#"version: "1.0""#), "config valide: {text}");
assert!(text.contains("smelt"), "agent recommande pour rust: {text}");
// Un second appel sans --force échoue.
assert!(init_cmd::generate(dir.path(), false).is_err());
assert!(init_cmd::generate(dir.path(), false, None).is_err());
// Avec --force, il écrase.
assert!(init_cmd::generate(dir.path(), true).is_ok());
assert!(init_cmd::generate(dir.path(), true, None).is_ok());
}
#[test]
fn generate_uses_default_for_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let path = init_cmd::generate(dir.path(), false).unwrap();
let path = init_cmd::generate(dir.path(), false, None).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("claude-code"), "default agent: {text}");
}
@@ -41,6 +41,6 @@ fn generate_uses_default_for_empty_dir() {
#[test]
fn command_runs_dry() {
let app = common::test_app("initdry", &["init", "--dry-run"]);
let code = init_cmd::run(&app, false).unwrap();
let code = init_cmd::run(&app, false, None).unwrap();
assert_eq!(code, 0);
}
+2 -2
View File
@@ -45,7 +45,7 @@ agents:
#[test]
fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", &[OsString::from("hello")]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, false, &[OsString::from("hello")]).unwrap();
assert_eq!(code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
#[test]
fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", &[]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, false, &[]).unwrap();
assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty());
+169
View File
@@ -0,0 +1,169 @@
//! Integration tests: am lab (issue #62) and am plugins (issue #75).
mod common;
use agent_manager::cli::{Cli, LabArgs, PluginsArgs};
use agent_manager::commands::{lab_cmd, plugins_cmd};
use clap::Parser;
use std::path::PathBuf;
/// A config with two shell-based fake agents for the lab benchmark.
fn lab_config() -> String {
concat!(
"agents:\n",
" - name: bench-ok\n",
" installable: false\n",
" run: sh\n",
" args: [\"-c\", \"echo out: $0\"]\n",
" - name: bench-fail\n",
" installable: false\n",
" run: sh\n",
" args: [\"-c\", \"echo boom >&2; exit 3\"]\n",
)
.to_string()
}
#[test]
fn lab_runs_a_task_on_every_agent_and_reports() {
let dir = std::env::temp_dir().join(format!("am-lab-e2e-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = common::write_config(&dir, &lab_config());
let task = dir.join("hello.yaml");
std::fs::write(
&task,
"name: hello\ndescription: e2e\nargs: [\"hello\"]\ntimeout: 30\n",
)
.unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"lab",
"--agents",
"bench-ok",
"--task",
"hello.yaml",
]);
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
let code = lab_cmd::run(
&app,
&LabArgs {
agents: Some("bench-ok, bench-fail".to_string()),
task: Some(task.clone()),
parallel: false,
timeout: None,
list: false,
},
)
.unwrap();
// One agent fails → the exit code is 1 (CI-friendly).
assert_eq!(code, 1);
// The benchmark is replayable with the same file.
let code2 = lab_cmd::run(
&app,
&LabArgs {
agents: Some("bench-ok".to_string()),
task: Some(task),
parallel: false,
timeout: None,
list: false,
},
)
.unwrap();
assert_eq!(code2, 0);
// Each run was journaled as a lab event.
let events = agent_manager::events::read_events(&app.events_dir(), 0);
let lab_events = events.iter().filter(|e| e.kind == agent_manager::events::EventKind::Lab);
assert!(lab_events.count() >= 3, "expected >= 3 lab events");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lab_list_shows_the_tasks_of_the_lab_dir() {
let dir = std::env::temp_dir().join(format!("am-lab-list-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = common::write_config(&dir, "agents: []\n");
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "lab", "--list"]);
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
std::fs::create_dir_all(app.events_dir().join("lab")).unwrap();
std::fs::write(app.events_dir().join("lab/hello.yaml"), "name: hello\n").unwrap();
let code = lab_cmd::run(
&app,
&LabArgs {
agents: None,
task: None,
parallel: false,
timeout: None,
list: true,
},
)
.unwrap();
assert_eq!(code, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plugin_runs_on_an_emitted_event() {
let dir = std::env::temp_dir().join(format!("am-plugins-e2e-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = common::write_config(&dir, "agents: []\n");
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "plugins"]);
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
// Install an echo plugin: valid JSON on stdout (contract) + a marker
// file proving it ran. The absolute path keeps it independent of cwd.
let pdir = app.events_dir().join("plugins");
std::fs::create_dir_all(&pdir).unwrap();
let marker = pdir.join("pong.log");
let script = format!(
"#!/bin/sh\ncat >/dev/null\necho '{{\"ok\": true, \"message\": \"pong\"}}' | tee '{}'\n",
marker.display().to_string().replace('\\', "/")
);
std::fs::write(pdir.join("pong.sh"), script).unwrap();
let script_path = pdir.join("pong.sh").display().to_string().replace('\\', "/");
std::fs::write(pdir.join("pong.yaml"), format!("command: \"sh {script_path}\"\n")).unwrap();
// List: the plugin is discovered.
let code = plugins_cmd::run(&app, &PluginsArgs { test: None }).unwrap();
assert_eq!(code, 0);
// --test: the JSON contract is exercised.
let code = plugins_cmd::run(&app, &PluginsArgs { test: Some("pong".to_string()) }).unwrap();
assert_eq!(code, 0);
// A real emitted event fires the plugin (acceptance criterion).
app.emit(&agent_manager::events::Event::now(
agent_manager::events::EventKind::Start,
));
assert!(marker.exists(), "the plugin must have run on the emitted event");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plugins_list_and_test_are_available_in_cli() {
let cli = Cli::parse_from(["am", "plugins", "--test", "notify"]);
match cli.command {
Some(agent_manager::cli::Command::Plugins(args)) => {
assert_eq!(args.test.as_deref(), Some("notify"));
}
other => panic!("unexpected: {other:?}"),
}
let cli = Cli::parse_from(["am", "lab", "--agents", "a,b", "--task", "t.yaml"]);
match cli.command {
Some(agent_manager::cli::Command::Lab(args)) => {
assert_eq!(args.agents.as_deref(), Some("a,b"));
assert_eq!(args.task, Some(PathBuf::from("t.yaml")));
}
other => panic!("unexpected: {other:?}"),
}
}
+5 -5
View File
@@ -58,7 +58,7 @@ fn agent_session_roundtrip_and_counter() {
fn command_lists_sessions() {
let app = common::test_app("sesscmd", &["sessions"]);
sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, None, None, None, None).unwrap();
assert_eq!(code, 0);
// --json path returns the row set without error.
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
@@ -67,9 +67,9 @@ fn command_lists_sessions() {
#[test]
fn resume_unknown_or_repl_session_errors() {
let app = common::test_app("resume", &["sessions"]);
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue")).is_err());
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue"), None, None, None).is_err());
sessions::start_repl(&app, "r1", None).unwrap();
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1")).is_err());
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1"), None, None, None).is_err());
}
#[test]
@@ -77,7 +77,7 @@ fn resume_dry_run_announces() {
let app = common::test_app("resumedry", &["sessions", "--dry-run"]);
let id = sessions::start_agent(&app, "claude-code", 4242, None, None, vec![], vec![], None).unwrap();
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id)).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id), None, None, None).unwrap();
assert_eq!(code, 0, "dry-run resume");
}
@@ -85,6 +85,6 @@ fn resume_dry_run_announces() {
fn show_one_accepts_known_id() {
let app = common::test_app("sessshow", &["sessions"]);
let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None, None, None, None).unwrap();
assert_eq!(code, 0);
}

Some files were not shown because too many files have changed in this diff Show More