Compare commits

...
65 Commits
Author SHA1 Message Date
bruno 4f4cd034b2 v0.4.0 : Phase 1 partielle (13/17) - recherche fuzzy, reexecution d'historique, projects+profils, start contextuel, timeline, sessions resume, hooks, watch, secret (keyring), top, report, log --follow 2026-08-17 09:14:48 -04:00
bruno aeca797ae0 Merge pull request 'secret : trousseau OS + injection @secret (issue #36)' (#86) from feat/secrets into main 2026-08-17 09:09:16 -04:00
bruno 354c914c43 secret : trousseau OS (keyring) + injection @secret au start/run (issue #36) - valeurs jamais affichees ni journalisees, mock testable, fallback clair sans trousseau 2026-08-17 09:09:04 -04:00
bruno c57d3a333d Merge pull request 'top + report + log --follow (issues #37 #38)' (#85) from feat/top-report into main 2026-08-17 09:03:58 -04:00
bruno 16926ffb00 top + report + log --follow (issues #37 #38) - top 10 par lancements, digest markdown hebdo/mensuel, suivi du journal en direct 2026-08-17 09:03:44 -04:00
bruno f14b59e63b Merge pull request 'watch : supervision + redémarrage automatique (issue #35)' (#84) from feat/watch into main 2026-08-17 08:58:32 -04:00
bruno 8d93eece1e watch : supervision d'un agent avec redemarrage automatique et backoff (issue #35) - check_once testable, notification desktop, Ctrl-C n'affecte pas l'agent detache 2026-08-17 08:58:19 -04:00
bruno 2a1fcfeed6 Merge pull request 'hooks : on_install/on_start/on_stop/on_update (issue #34)' (#83) from feat/hooks into main 2026-08-17 08:55:49 -04:00
bruno 18784ba9f7 hooks : on_install/on_start/on_stop/on_update globaux (settings.hooks) et par projet (issue #34) - echec non bloquant, dry-run respecte, ordre global puis projet 2026-08-17 08:55:35 -04:00
bruno 3220a323ef Merge pull request 'sessions : am sessions --resume (issue #32)' (#82) from feat/sessions-resume into main 2026-08-17 08:48:45 -04:00
bruno 30cc49de3c sessions : am sessions --resume <id> (issue #32) - relance en arriere-plan avec les arguments et le cwd d'origine, cwd optionnel dans spawn_background, gardes running/repl/erreurs claires 2026-08-17 08:48:30 -04:00
bruno 314583ceee Merge pull request 'timeline : vue chronologique unifiée (issue #31)' (#81) from feat/timeline into main 2026-08-17 08:45:16 -04:00
bruno ed795c522f timeline : vue chronologique unifiee journal + historique REPL (issue #31) - filtres agent/projet/date, limite, parse_since partage 2026-08-17 08:45:03 -04:00
bruno bbc2bb889d roadmap : decoupage des phases 2 (v0.5.0, issues #47 #49-#75) et 3 (v1.0, issues #48 #76-#80) sur Gitea 2026-08-17 08:15:16 -04:00
bruno 904e340cdd Merge pull request 'start contextuel : agent par défaut du projet (issue #30)' (#46) from feat/start-context into main 2026-08-17 08:13:35 -04:00
bruno 5f8b973789 start contextuel : am start sans argument lance l'agent par defaut du projet (issue #30) - resolution pure resolve_start_target, restart identique, REPL sans agent obligatoire 2026-08-17 08:13:19 -04:00
bruno 463fa6b937 Merge pull request 'projects : am projects + profils de projet (issue #29)' (#45) from feat/projects into main 2026-08-17 08:10:57 -04:00
bruno dc7372f277 projects : am projects (agregats par depot depuis le journal) + profils de projet dans la config (issue #29) - agent dominant, sessions, durees, cache projects.json rafraichi par doctor --fix 2026-08-17 08:10:42 -04:00
bruno a23bc6b5ae Merge pull request 'history : réexécution !! !N ^old^new + am history --rerun (issues #27 #28)' (#44) from feat/history-rerun into main 2026-08-17 08:06:16 -04:00
bruno 81c69db17b history : reexecution (issues #27 #28) - expansion !! !N !prefix ^old^new dans le REPL + am history --rerun N avec confirmation (shell ou commande am) 2026-08-17 08:05:58 -04:00
bruno 9e6c8aca08 Merge pull request 'search : recherche fuzzy + suggestions (issue #26)' (#43) from feat/fuzzy-search into main 2026-08-17 08:01:56 -04:00
bruno 4ef89cde72 search : recherche fuzzy avec scoring + suggestions vouliez-vous-dire (issue #26) - sous-chaine, subsequence, tolerance de frappe (Levenshtein), tri par pertinence 2026-08-17 08:01:39 -04:00
bruno 2eaafe6e2a v0.3.0 : picoclaw en binaire 64-bit officiel (sipeed/picoclaw releases) + shims git en .cmd sur Windows (fini le batch renomme en .exe qui donnait Unsupported 16-Bit Application) avec routage via cmd.exe 2026-08-17 07:52:17 -04:00
bruno deeb489b4e v0.3.0 : menu d autocompletion - les barres verticales des menus en colonne unique (ex. completion de theme) prennent la couleur du cadre du theme 2026-08-17 06:55:07 -04:00
bruno fc243d2f21 docs: decoupage Phase 1 (v0.4.0) dans le ROADMAP - 17 issues + epique, milestone v0.4.0 2026-08-16 23:20:07 -04:00
bruno 6ecfa0d21c v0.3.0 : Phase 0 du ROADMAP livree - journal d'evenements, sessions, stats, log, historique structure, am init, quick wins; README et ROADMAP a jour 2026-08-16 22:48:53 -04:00
bruno 3902bbcb37 Merge pull request 'quick wins #9-#13 et #16 : logs, list --sort, config set, alias, dedup, open' (#24) from feat/quick-wins into main 2026-08-16 22:45:27 -04:00
bruno d4a6aeae1d quick wins (issues #9 #10 #11 #12 #13 #16) : am logs (tail+follow), am list --running/--sort, am config set, am alias add/remove/list, dedup historique, am open 2026-08-16 22:45:11 -04:00
bruno 38d0729cb3 Merge pull request 'init + contexte : am init et détection de projet (issue #8)' (#23) from feat/init-context into main 2026-08-16 22:40:15 -04:00
bruno 85d44f9e56 init+contexte : am init genere un agent-manager.yaml selon la stack detectee + contexte projet (git root, branche, stack) attache aux evenements et sessions (issue #8) 2026-08-16 22:40:00 -04:00
bruno 48e85555f7 Merge pull request 'history : historique structuré + am history (issue #7)' (#22) from feat/history into main 2026-08-16 22:35:01 -04:00
bruno c9e2543f50 history : historique structure par session + am history (issue #7) - migration one-shot de history.txt, filtres kind/cwd/search/failed/session, enregistrement REPL avec duree et exit 2026-08-16 22:34:48 -04:00
bruno 9f4a13eae2 Merge pull request 'stats + log : observabilité du journal (issue #6)' (#21) from feat/stats-log into main 2026-08-16 22:30:54 -04:00
bruno 33748bea36 stats+log : am stats (agregats par agent, periodes 7d/30d/90d, barres ASCII) et am log (filtres agent/kind/date, limite) (issue #6) 2026-08-16 22:30:41 -04:00
bruno 19e77fd261 Merge pull request 'sessions : registre central + am sessions (issue #5)' (#20) from feat/sessions into main 2026-08-16 22:22:23 -04:00
bruno 38b7a3a915 sessions : registre des sessions + commande am sessions (issue #5) - lifecycle agent/repl, reconciliation des sessions interrompues, compteur sessions_count, filtres agent/projet/statut, show avec extrait de log 2026-08-16 22:22:09 -04:00
bruno 3af0834bae Merge pull request 'state : v2 — sessions_count et last_used (issue #4)' (#19) from feat/state-v2 into main 2026-08-16 22:16:56 -04:00
bruno c6518bcfd5 state : v2 avec sessions_count et last_used (issue #4) - migration additive persistee au premier contact, touch() sur start/run, fixture v1 testee 2026-08-16 22:16:44 -04:00
bruno a672880e34 Merge pull request 'events : instrumentation des commandes (issue #3)' (#18) from feat/instrumentation into main 2026-08-16 22:14:03 -04:00
bruno 310da96a63 events : instrumentation start/stop/run/install/update/uninstall/doctor/repl/shell (issue #3) - un evenement JSONL par action reelle, dry-run sans evenement, session id sur start, duree et exit code sur stop/run 2026-08-16 22:13:49 -04:00
bruno ba901df953 v0.2.14 : fallback web GitHub pour les releases - quand l API est rate-limitee, le tag vient de la redirection /releases/latest et les assets de expanded_assets (aucune limite) - jcode et zeroclaw installables sans GITHUB_TOKEN 2026-08-16 21:53:25 -04:00
bruno e50e8d3e41 v0.2.14 : kimi-cli via uv (paquet PyPI kimi-cli, fini le npm inexistant) + uv venv autonome (python 3.13 gere par uv, plus besoin de python systeme pour les installs uv) 2026-08-16 21:41:21 -04:00
bruno 498af684c9 v0.2.14 : support GITHUB_TOKEN/GH_TOKEN pour l API GitHub (limite 60 -> 5000 requetes/h) et message clair sur le rate limit 403 2026-08-16 21:30:31 -04:00
bruno 2282f71829 v0.2.14 : correction install binaire - detection des archives par magic bytes (un zip renomme n est plus copie tel quel), preference des archives sur les setup/installers, validation de l executable (MZ/ELF/Mach-O) avec erreur claire avant le premier start 2026-08-16 21:23:08 -04:00
bruno f968d7d0b0 v0.2.14 : 9 nouveaux themes - nord, solarized, gruvbox, catppuccin, tokyo-night, monokai, matrix, rose-pine, amber (15 au total), liste des themes sur deux lignes dans la banniere 2026-08-16 21:01:15 -04:00
bruno b3437b0601 v0.2.14 : correction bordure haute du splash - les tirets a droite du titre gardent la couleur du theme (reset du titre ne casse plus le cadre) 2026-08-16 20:45:21 -04:00
bruno 843c3e1278 Merge pull request 'events : journal d'evenements JSONL (issue #2)' (#17) from feat/events-ledger into main 2026-08-16 13:50:24 -04:00
bruno 4db567eebe events : journal d'evenements JSONL (issue #2) - append-only, rotation mensuelle, lecture tolerante aux lignes corrompues, env_keys sans valeurs secretes, 8 tests 2026-08-16 13:49:11 -04:00
bruno 2bbe45e508 v0.2.14 : barres verticales des cadres aux couleurs du theme + commande theme dans le shell (theme liste les themes, theme <name> change a chaud, completion et menu synchronises) 2026-08-16 13:40:51 -04:00
bruno 4b0f02a7c4 v0.2.14 : cadres aux couleurs du theme - bordures des tableaux (list, search, status, doctor, ls, dir, ps, help), version, menu d autocompletion et banniere colorees par theme (border) 2026-08-16 13:17:07 -04:00
bruno 79143e197d v0.2.14 : splash screen theme - art ASCII et bordures aux couleurs du theme, section Theme (theme actif + liste des themes disponibles), ligne de statut theme 2026-08-16 13:10:07 -04:00
bruno 14f873bfdd v0.2.14 : aide Nushell, themes de couleurs et cadres blancs
- Aide facon Nushell : help <cmd>, <cmd> -h/--help, help, help commands,
  help --find <texte>, help <agent> (fiche info) - sections Usage/Flags/
  Command Type/Parameters/Input-output/Examples avec couleurs
- Commande version : tableau Nushell (commit, build, rustc, theme...) + --json ;
  build.rs pour commit/branche git ; -V reste la version courte
- Themes : --theme <nom> (default, ocean, sunset, forest, dracula, mono)
  + settings.theme dans la config ; activation VT Windows pour les couleurs
- Cadres blancs Nushell partages : list, search, status, doctor, ls/ps/
  where/get et menu d autocompletion Tab encadre (colonnes name/description)
2026-08-16 12:52:12 -04:00
bruno 53f3fa34e7 docs: roadmap section 11 liee aux issues Gitea (suivi phase 0 en place) 2026-08-16 12:51:52 -04:00
bruno 5841926b25 docs: roadmap v2 - lecture facilitee (sommaire, legende, quick wins, KPI), 12 axes enrichis (modeles locaux, lab, rapports, couts, favoris, profils), jalons versionnes 2026-08-16 12:31:29 -04:00
bruno 979875fa63 v0.2.13 : prompt avec emojis colores — 🗂️ repertoire, 🤖 catalogue, 🌐 externes, 📊 geres, ⚡ duree de la derniere commande, ⏲ session 2026-08-16 10:43:26 -04:00
bruno 93c32b60ba v0.2.12 : ligne de statut — temps dexecution de la derniere commande (⚡ ms/s) + ls parallele (tailles de dossiers reparties sur les coeurs, un seul appel de metadonnees par entree) — 130 entrees instantanees 2026-08-16 10:29:38 -04:00
bruno 0f4ba71219 v0.2.11 : ligne de statut et banniere — compteurs distincts agents externes (PATH) et agents geres (am install) pour lever l ambiguite du 0 installed 2026-08-16 10:08:54 -04:00
bruno e7cb136c38 v0.2.10 : affichage ls/dir adapte a la largeur du terminal — detection de la largeur de la console (AM_TABLE_WIDTH pour tests), troncature de la colonne nom avec …, suppression des colonnes de fin si necessaire, une ligne par entree 2026-08-16 09:38:54 -04:00
bruno 57234b7a38 v0.2.9 : ps/where/get facon Nushell avec mini-pipeline (ls | where size > 1mb | get name), dir = ls sur Windows, ls beaucoup plus rapide (taille des dossiers bornee a 500 entrees), autocomplet : prefixe commun + menu avec descriptions + fallback chemins 2026-08-16 09:11:09 -04:00
bruno 3e1b66809a v0.2.8 : navigation facon Nushell — commande ls en tableau encadre (type, taille, date), completion Tab des dossiers pour cd (niveau par niveau) et des chemins pour ls, descriptions dans le menu Tab 2026-08-15 23:42:32 -04:00
bruno d886de322c v0.2.7 : self-uninstall supprime maintenant le binaire — renommage immediat de l executable en cours dexecution (am.exe.deleteme) + script .bat temporaire avec reessais, balayage des dossiers vides et auto-suppression 2026-08-15 23:19:28 -04:00
bruno bc38cd59e5 v0.2.6 : colonne AGENT (slug) dans am list + desinstallation des agents externes (npm/pip/uv/cargo/bun ou suppression des fichiers, shims .cmd gerees) 2026-08-15 22:20:51 -04:00
bruno 61f4909bc6 v0.2.5 : info enrichi (statut, versions, dependances verifiees) + self-uninstall tolerant (fichiers verrouilles / lecture seule, balayage des dossiers vides) + agent deepseek-harness au catalogue 2026-08-15 21:10:14 -04:00
bruno 98aa75d185 doc : desinstallation Windows — commande PowerShell corrigee + variante cmd clarifiee 2026-08-15 20:09:43 -04:00
bruno ba07f42d09 v0.2.4 : prompt interactif style Hermes + passerelle shell systeme (shell <nom>, detection du shell par defaut) + desinstallation propre (self-uninstall) 2026-08-15 19:59:24 -04:00
71 changed files with 12121 additions and 295 deletions
Generated
+13 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.3"
version = "0.3.0"
dependencies = [
"anyhow",
"chrono",
@@ -29,6 +29,7 @@ dependencies = [
"clap_complete",
"colored",
"flate2",
"keyring",
"rustyline",
"semver",
"serde",
@@ -41,6 +42,7 @@ dependencies = [
"ureq",
"wait-timeout",
"which",
"windows-sys 0.52.0",
"zip",
]
@@ -714,6 +716,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"log",
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.2.3"
version = "0.4.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"
@@ -32,9 +32,13 @@ tar = "0.4"
tempfile = "3"
ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2"
keyring = "3"
which = "7"
zip = "0.6"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Console"] }
[profile.release]
lto = "thin"
strip = true
+106 -10
View File
@@ -76,7 +76,7 @@ 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 par nom, description, catégorie, tag |
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs |
@@ -90,29 +90,50 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am install <agent> | installe (dépendances vérifiées et proposées) |
| am install <agent> --method pip | choisit une méthode d'installation |
| am update <agent> | met à jour vers la dernière version connue |
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) |
| am config set settings.default_shell pwsh | modifie la configuration sans éditeur |
| am alias add cc claude-code | raccourci pour un agent (list / remove aussi) |
| am init | génère un agent-manager.yaml selon la pile du projet (Node/Rust/Python…) |
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
| am export / am import | sauvegarde et restaure config + état |
### 🚀 Exécuter
| Commande | Rôle |
|----------|------|
| am start <agent> | avant-plan (Ctrl-C pour quitter) |
| am start <agent> --background --env KEY=VALEUR | détaché, PID et log gérés |
| am start [agent] | avant-plan (Ctrl-C pour quitter) — sans argument, lance l'agent par défaut du projet |
| am start <agent> --background --env KEY=VALEUR | détaché, PID et log gérés ; --env NOM=@secret injecte un secret du trousseau |
| am stop <agent> | arrêt gracieux puis forcé (--force : immédiat) |
| am restart <agent> | arrête puis redémarre |
| am run <agent> [args…] | exécution directe, sans gestion de processus |
### 📊 Observer & retrouver
| Commande | Rôle |
|----------|------|
| am stats [agent] --period 7d/30d/90d | statistiques d'utilisation : lancements, durées, échecs, barres ASCII |
| am top --period | les 10 agents les plus utilisés |
| am report --last-week / --last-month | digest markdown (lancements, échecs, changements, sessions) |
| am log [agent] --kind --since --follow | journal des événements : tout ce que am a fait, en direct avec --follow |
| am timeline --project --since | vue chronologique unifiée (journal + commandes du REPL) |
| 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 logs <agent> --follow | tail du log d'un agent, en direct |
### 🧰 Système
| Commande | Rôle |
|----------|------|
| am doctor | vérifie outils, config, chemins — avec commandes d'installation |
| am doctor --fix | répare ce qui peut l'être, propose d'installer les outils manquants |
| am config show / edit / add | gère votre configuration |
| am watch <agent> --restart --notify | supervise un agent et le relance en cas de crash |
| am secret set NOM --agent A --value … / list / unset | secrets dans le trousseau du système (jamais en clair) |
| am config show / edit / add / set | gère votre configuration (hooks on_start/on_stop/… dans settings.hooks) |
| am open <agent> | ouvre le répertoire d'installation dans l'explorateur |
| am completion <shell> | script de complétion (bash, zsh, fish, powershell) |
| am self-update | met à jour am lui-même (si configuré) |
| am | shell interactif avec Tab et historique |
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
| am | shell interactif : bannière, passerelle système, Tab et historique |
---
@@ -166,10 +187,70 @@ Disponibles avant **ou après** la sous-commande.
am start group:dev # démarre tout un groupe en arrière-plan
am stop group:dev
am # shell interactif :
am> inst<Tab> # complétion Tab : commandes, agents, groupes, options
am> install jcode
am> exit # historique persistant (flèches haut/bas)
am # shell interactif (bannière style Hermes) :
❯ inst<Tab> # complétion Tab : commandes, agents, groupes, options
❯ install jcode
❯ exit # historique persistant (flèches haut/bas)
### 🐚 Passerelle système
Le prompt interactif est aussi une **passerelle vers votre shell
système** : toute saisie qui n'est pas une commande am est exécutée par le
shell actif. Le **shell par défaut de l'utilisateur est détecté au
démarrage et mis en évidence** dans la bannière (config
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
❯ ls # liste façon Nushell : tableau encadré (type, taille, date)
❯ dir # sur Windows, même affichage que ls
❯ ls src # un chemin précis — Tab complète fichiers et dossiers
❯ ps # liste des processus (pid, ppid, cpu, mem, threads)
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
❯ ps | where name =~ am # =~ cherche dans le texte
❯ ls | get name # sélectionne une colonne
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
❯ shell # affiche le shell courant + les shells disponibles
❯ shell bash # change le shell de la session
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
❯ cd ~/projets # change le répertoire de la session (persistant)
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
Le menu Tab affiche une **description** à côté de chaque candidat
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
chaque Tab.
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
`config.yaml` (voir `am config path`).
---
## 🧹 Désinstallation propre
Une seule commande, sur tous les OS — elle arrête d'abord les agents en
arrière-plan, puis supprime les données, l'état (logs, historique), la
configuration, et enfin l'exécutable lui-même :
am self-uninstall # avec confirmation
am self-uninstall --yes # automatique, en une ligne
Sans le binaire (ou en dernier recours), supprimez les répertoires
qu'agent-manager possède :
# Linux
rm -rf ~/.local/share/agent-manager ~/.local/state/agent-manager ~/.config/agent-manager
# macOS
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
# Windows — PowerShell
Remove-Item -Recurse -Force $env:LOCALAPPDATA\agent-manager, $env:APPDATA\agent-manager -ErrorAction SilentlyContinue
# Windows — cmd.exe (à ne PAS coller dans PowerShell : rmdir y est un
# alias de Remove-Item et le & final lance un job en arrière-plan)
rmdir /s /q "%LOCALAPPDATA%\agent-manager" & rmdir /s /q "%APPDATA%\agent-manager"
(Si vous utilisiez `AGENT_MANAGER_DATA`/`AGENT_MANAGER_STATE`/`AGENT_MANAGER_CONFIG_DIR`,
supprimez aussi ces emplacements.)
---
@@ -233,6 +314,21 @@ multi-fichiers pris en charge ; configuration validée au démarrage.
(Windows) — l'état et les logs dans ~/.local/state/agent-manager (Linux) ou
%LOCALAPPDATA%\\agent-manager (Windows).
**Où vivent les statistiques, les sessions et l'historique ?** Dans le
répertoire d'état, au format JSON lisible et 100 % local : events-YYYYMM.jsonl
(journal de chaque action), sessions.json (registre des sessions),
history/ (historique structuré du shell), projects.json (agrégats par
projet). Interrogeables avec am stats, am top, am log, am timeline,
am projects, am sessions et am history.
**Où vivent les secrets ?** Dans le trousseau de votre système (Windows
Credential Manager, macOS Keychain, Linux Secret Service) — jamais dans la
configuration ni dans les logs. Injection avec --env NOM=@secret.
**Puis-je automatiser des actions ?** Oui : hooks on_install/on_start/on_stop/
on_update dans settings.hooks (globaux) ou dans le profil d'un projet, et
am watch pour superviser un agent avec redémarrage automatique.
**Comment mettre à jour am ?** Relancez l'installeur one-liner (ou
am self-update si le dépôt de release est configuré).
+480
View File
@@ -0,0 +1,480 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.1** · Statut : propositions, non engagées
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
>
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
> 157 tests, release publiée sur Gitea. Les découpages des phases 1 à 3 sont
> en place sur Gitea (issues #25 à #80) : v0.4.0, v0.5.0, v1.0.
>
> 🔵 **Phase 1 livrée partiellement en v0.4.0 (2026-08-17)** : 13/17 issues
> clôturées (#26–#32, #34–#38), 187 tests. Restent : #33 dashboard TUI,
> #39 favoris/notes/tags, #40 profils d'environnement, #41 complétions+man,
> #42 packaging.
---
## 📖 Sommaire
1. [Résumé exécutif](#1--résumé-exécutif)
2. [Légende de lecture](#2--légende-de-lecture)
3. [Quick wins — valeur immédiate](#3--quick-wins--valeur-immédiate)
4. [État des lieux](#4--état-des-lieux)
5. [Vision & principes](#5--vision--principes)
6. [Socle technique : le modèle de données](#6--socle-technique--le-modèle-de-données)
7. [Les 12 axes](#7--les-12-axes)
8. [Jalons versionnés](#8--jalons-versionnés)
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
10. [Risques & garde-fous](#10--risques--garde-fous)
11. [Par où commencer](#11--par-où-commencer)
---
## 1. 📌 Résumé exécutif
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
Mais il est **aveugle** : il ne retient ni ce qui a été fait, ni quand, ni
par quel agent, ni dans quel projet, ni avec quel résultat. Les logs sont du
texte libre non interrogeable, l'état ne contient que les installations et
le PID courant, l'historique est un simple fichier plat.
Cette roadmap transforme **am** en **cockpit de votre parc d'agents IA** :
| Rôle | Promesse | Porté par |
|---|---|---|
| 📓 Journal de bord | tout est tracé, rien ne se perd | Axe 1 (journal d'événements) |
| 🔎 Rétroviseur | tout est cherchable en 2 secondes | Axes 2, 3, 4 |
| 📊 Tableau de bord | tout est mesuré | Axes 1, 5, 9 |
| 🤖 Copilote | il pilote à votre place | Axes 6, 7 |
**Chiffres clés :** 12 axes · ~55 fonctionnalités · 4 jalons (v0.3.0 → v1.0)
· ~15 semaines de travail cumulé · 0 dépendance runtime ajoutée.
---
## 2. 📚 Légende de lecture
| Symbole | Signification |
|---|---|
| **P0** | Fondations — à démarrer tout de suite (socle des autres axes) |
| **P1** | Court terme — après la phase 0 |
| **P2** | Moyen terme |
| **P3** | Vision — long terme |
| ⭐ | Reprend une demande explicite de l'utilisateur |
| **S** | Effort : moins d'1 jour |
| **M** | Effort : 2 à 4 jours |
| **L** | Effort : 1 à 2 semaines |
| **XL** | Effort : plus de 2 semaines |
| ⬜ / 🔨 / ✅ | Proposé / En cours / Livré (rien n'est commencé aujourd'hui) |
---
## 3. ⚡ Quick wins — valeur immédiate
*Tous en effort S, phase P0, sans dépendance sur le socle. Livrables en
quelques jours, à caler avant ou pendant la construction du journal.*
| Fonctionnalité | Commande | Apport |
|---|---|---|
| Voir les logs d'un agent | am logs claude-code (--follow) | tail des logs déjà écrits par process.rs |
| Filtres sur la liste | am list --running · --sort name,version,status | repérage instantané |
| Éditer la config sans éditeur | am config set key value | moins de friction YAML |
| Alias à la volée | am alias add cc claude-code | sans éditer le fichier |
| Historique propre | dédup + horodatage de history.txt | base saine pour l'axe 2 |
| Doctor scriptable | am doctor --json | CI et monitoring |
| Ouvrir l'installation | am open claude-code | explorer le répertoire de l'agent |
| Version scriptable | am version --json | introspection des scripts |
---
## 4. 🧭 État des lieux (points d'ancrage dans le code)
| Brique actuelle | Fichier | Limite aujourd'hui |
|---|---|---|
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
| Installation (9 méthodes), dépendances OS | src/installers/, src/deps.rs, src/toolchain.rs | solide — rien à redire |
| État local | src/state.rs (state.json v1) | installations + PID courant uniquement, **aucun historique** |
| Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
| Processus | src/process.rs | logs non structurés, pas de sessions, pas de métriques |
| Journal applicatif | src/output.rs | texte libre horodaté, non interrogeable |
| REPL + passerelle shell | src/repl.rs (history.txt, session_id()) | historique plat, pas de recherche ni de stats |
| Backup | src/commands/export_import.rs | manuel, pas de synchronisation |
---
## 5. 🎯 Vision & principes directeurs
**Vision :** le cockpit unique du parc d'agents — journal de bord, rétroviseur,
tableau de bord et copilote.
**Principes :**
1. **Zéro dépendance runtime** — tout est Rust pur et fichiers locaux
(JSONL/JSON), jamais de base de données externe.
2. **Local par défaut, opt-in pour le distant** — la télémétrie n'existe que
si l'utilisateur l'active ; tout le reste est 100 % local.
3. **Contrats stables** — les sorties --json sont la base des scripts et des
futurs UI : champs ajoutés, jamais retirés.
4. **Performance** — écriture en append (JSONL), lectures par fenêtres
temporelles, index dérivés régénérables, caches sur le modèle de probe.rs.
5. **Livraison continue** — chaque phase produit un binaire utilisable et
testé ; la barre des 45+ tests monte à chaque jalon.
---
## 6. 🧱 Socle technique : le modèle de données
*Tout dépend de ceci. À construire en premier, avant les commandes visibles.*
state_dir/
state.json # v2 : + sessions_count, last_used (migration auto depuis v1)
events.jsonl # NOUVEAU — journal d'événements (source de vérité)
events-202608.jsonl # rotation mensuelle
sessions.json # NOUVEAU — registre des sessions (index dérivé)
history/
<session>.jsonl # NOUVEAU — historique structuré du REPL
projects.json # NOUVEAU — agrégats par projet (recalculable)
probe-cache.json # existant — pattern de cache à suivre
| Fichier | Rôle | Alimenté par | Reconstruction |
|---|---|---|---|
| events.jsonl | faits bruts (start, stop, run, install…) | process.rs, run_cmd.rs, install_cmd.rs, repl.rs, doctor_cmd.rs | source de vérité, jamais reconstruit |
| sessions.json | index des sessions | dérivé du journal | doctor --fix (rejoue le journal) |
| projects.json | agrégats par projet | dérivé du journal | doctor --fix |
| history/*.jsonl | commandes REPL | repl.rs | non (données primaires) |
| state.json v2 | installations + résumé | state.rs | partiellement (compteurs depuis le journal) |
**Règles :** JSONL append-only pour les faits · JSON dérivés régénérables ·
migration additive (state v1 reste lisible partout) · doctor --fix répare
tout ce qui peut l'être.
---
## 7. 🗂️ Les 12 axes
### Axe 1 — 📊 Observabilité & statistiques d'utilisation ⭐
🎯 Savoir **quoi** a tourné, **quand**, **combien de temps**, avec **quel
taux de succès** — et **combien ça coûte**.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Journal d'événements events.jsonl (toutes les actions de am) | M | P0 |
| am stats + --period 7d/30d/90d + --json (tableaux et barres ASCII) | M | P0 |
| 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 |
| Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
am stats # vue globale
am stats claude-code --period 30d
am top # qui tourne le plus
am report --last-week # digest markdown
### Axe 2 — 💾 Historique des commandes ⭐
🎯 Gérer, **rechercher** et **réutiliser** tout ce qui a été tapé.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Historique structuré : history/<session>.jsonl (cmd, kind am/shell, cwd, durée, exit, agent) + migration de history.txt | M | P0 |
| 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 |
am history --search "install" --failed
am history --rerun 42
### Axe 3 — 🧭 Projets & workspaces ⭐
🎯 Centraliser **quel agent travaille sur quel projet** — automatiquement.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Contexte auto au lancement : racine + branche git, stack détectée (Cargo.toml, package.json…), mis en cache | S | P0 |
| am projects / am projects api — agents, sessions, durées, dernière activité | M | P1 |
| Profils de projet dans config : default_agent, env, hooks | M | P1 |
| am start (sans argument) = agent par défaut du dossier courant ; REPL contextuel | S | P1 |
| am init — génère un agent-manager.yaml selon la stack détectée | S → M | P0 |
| Templates : am init --template web | M | P2 |
am projects
am projects api
am init # dans un projet Node → config pré-remplie
### Axe 4 — 🗂️ Sessions centralisées ⭐
🎯 Un registre unique de **toutes les sessions de tous les agents** —
consultable, reprenable, archivable.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| sessions.json + session_id() partagé (repl.rs) ; fin de session avec exit code et durée | M | P0 |
| Réconciliation au démarrage : sessions interrompues (crash, reboot) marquées et datées | S | P0 |
| am sessions — filtres agent/projet/statut ; show = résumé + extrait de log | M | P0 |
| am timeline — vue chronologique unifiée de toute l'activité | M | P1 |
| am sessions resume <id> — relance avec les mêmes args/env/cwd | M | P1 |
| am sessions export <id> + rétention configurable (settings.sessions_retention_days) | M | P2 |
am sessions --status failed
am sessions show 20260815_143926_a1b2c3
am timeline --project api
### Axe 5 — 🖥️ Tableau de bord
🎯 Voir l'état du parc d'un coup d'œil.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| am dashboard — TUI (ratatui/crossterm) : vue d'ensemble, activité en direct, stats, sessions, projets | L | P1 |
| am web — serveur local + API JSON + page HTML embarquée avec graphiques | XL | P2 |
### Axe 6 — 🤖 Automatisation & orchestration
🎯 Laisser am **superviser et piloter seul** le parc.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| 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 |
### Axe 7 — 🧠 Intelligence & catalogue
🎯 Trouver le bon agent, suivre leurs sorties, et comparer.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 |
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 |
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
| Registre communautaire : publier son catalogue (Gitea) + am registry | L | P3 |
| am ask "installe claude et lance-le" — langage naturel → commande am (fournisseur LLM configurable, optionnel) | L | P3 |
### Axe 8 — 🔒 Sécurité & gouvernance
🎯 Protéger les secrets, tracer les changements, pouvoir revenir en arrière.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| am secret set OPENAI_KEY --agent claude-code (keyring OS) + injection --env automatique | M | P1 |
| am audit — qui a modifié quoi quand (checksums des configs, événements) | M | P2 |
| am update --rollback — backup automatique avant chaque mise à jour | M | P2 |
| Politiques : pin de version, settings.update_policy | S | P2 |
| Profils sandbox par agent (commandes/répertoires autorisés) | L | P3 |
### Axe 9 — 🌐 Multi-machine & collaboration
🎯 Retrouver son cockpit partout, et le partager.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 |
| Partage de catalogue d'équipe : include par URL | S | P2 |
| am migrate — assistant de transfert machine A → B | M | P2 |
| am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
### Axe 10 — 🧩 Confort & personnalisation
🎯 Adapter am à sa façon de travailler.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
| Notes : am note claude-code "utiliser pour …" | S | P1 |
| Tags personnels : am tag claude-code python | S | P1 |
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 |
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
| Thèmes couleurs du REPL | S | P2 |
### Axe 11 — 🎛️ Modèles locaux
🎯 Étendre le cockpit aux modèles : **ollama, llama.cpp, LM Studio**.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| am models — inventaire : nom, taille disque, quantisation, dernière utilisation | M | P2 |
| Lien agent ↔ modèle : am run mon-agent --model llama3.1 | M | P2 |
| doctor vérifie ollama / llama-server comme n'importe quel outil | S | P2 |
| am models prune — purge des modèles inutilisés | S | P2 |
### Axe 12 — 🛠️ Écosystème & expérience développeur
🎯 Rendre am disponible partout et contribuable.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Fixtures d'événements rejouables + tests de bout en bout du journal | S | P0 |
| Packaging officiel : winget, scoop, deb/rpm, Homebrew + CI de release | M | P1 |
| Complétions dynamiques (agents installés, groupes personnalisés) | S | P1 |
| Man pages + documentation générée | S | P1 |
| i18n : messages EN/FR | L | P2 |
| Plugin scripts (hooks avancés, intégration CI) | M | P2 |
---
## 8. 🚀 Jalons versionnés
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|---|---|---|---|---|
| **J0 — Fondations** | v0.3.0 | events.jsonl, sessions, am stats, am log, am history, am init, quick wins | ≈ 2 sem | 100 % des actions tracées · 100+ tests |
| **J1 — Cockpit** | v0.4.0 | recherche fuzzy, Ctrl-R, projects, timeline, dashboard TUI, hooks, secret, favoris, packaging | ≈ 3 sem | retrouver n'importe quelle action passée en < 2 s |
| **J2 — Automatisation** | v0.5.0 | watch, services, schedule, news/compare, catalogue distant, lab, models, sync | ≈ 4 sem | parc auto-supervisé (crash = redémarrage + alerte) |
| **J3 — Plateforme** | v1.0 | am web, am serve, am ask, registry, télémétrie opt-in, sandbox | ≈ 6 sem | dashboard web complet + API distante |
---
## 9. 📈 Indicateurs de succès (KPI)
| Indicateur | Cible |
|---|---|
| Part des actions tracées dans events.jsonl | 100 % dès la v0.3.0 |
| Temps pour retrouver une commande ou session passée | < 2 s |
| am list à chaud, journal actif | < 100 ms |
| Dépendances runtime ajoutées | 0 |
| Couverture de tests | ≥ 100 tests en v0.3, +25 par jalon |
| Contrats --json cassés entre versions mineures | 0 |
---
## 10. ⚠️ Risques & garde-fous
1. **ADN « zéro runtime »** : toute dépendance nouvelle est compilée dans le
binaire, jamais requise à l'exécution.
2. **Performance** : le journal grossit — fenêtres temporelles, index dérivés,
rotation mensuelle, caches façon probe.rs.
3. **Vie privée** : local par défaut ; télémétrie opt-in, agrégée, anonyme.
4. **Contrats --json** : ajout de champs sans retrait (scripts utilisateurs).
5. **Compatibilité** : migrations additives ; les exports 0.2.x restent
importables en 0.3+.
6. **Complexité UX** : les fonctions avancées restent derrière
settings.experimental jusqu'à maturité.
7. **Périmètre** : rester un orchestrateur — ne pas réimplémenter les
fonctionnalités propres aux agents (tokens, MCP, prompts), sauf valeur
transversale (stats, sessions, secrets, coûts).
---
## 11. 🏁 Par où commencer
*Le suivi est en place sur Gitea : 4 milestones (v0.3.0 → v1.0), 22 labels
(P0–P3, axe-1…axe-12, S/M/L/XL, quick-win, epic) et les issues ci-dessous.
Chaque PR doit référencer son issue avec `closes #N` pour une clôture
automatique. Ordre de construction proposé pour la phase 0 — chaque étape
est un PR indépendant et testé :*
1. **[PR 1](https://git.dracodev.net/Projets/agent-manager/issues/2)** — src/events.rs : journal JSONL + rotation + tests.
2. **[PR 2](https://git.dracodev.net/Projets/agent-manager/issues/3)** — instrumentation start/stop/run (process.rs, run_cmd.rs) — dépend de PR 1.
3. **[PR 3](https://git.dracodev.net/Projets/agent-manager/issues/4)** — state.json v2 + migration additive — dépend de PR 1.
4. **[PR 4](https://git.dracodev.net/Projets/agent-manager/issues/5)** — sessions.json + am sessions + réconciliation — dépend de PR 2.
5. **[PR 5](https://git.dracodev.net/Projets/agent-manager/issues/6)** — am stats + am log (lecture du journal) — dépend de PR 2.
6. **[PR 6](https://git.dracodev.net/Projets/agent-manager/issues/7)** — historique structuré + am history (migration de history.txt).
7. **[PR 7](https://git.dracodev.net/Projets/agent-manager/issues/8)** — am init + détection de contexte (git, stack, cache) — dépend de PR 1.
8. **En parallèle** — les quick wins de la section 3 :
[am logs #9](https://git.dracodev.net/Projets/agent-manager/issues/9) ·
[am list --sort #10](https://git.dracodev.net/Projets/agent-manager/issues/10) ·
[am config set #11](https://git.dracodev.net/Projets/agent-manager/issues/11) ·
[am alias add #12](https://git.dracodev.net/Projets/agent-manager/issues/12) ·
[dedup history.txt #13](https://git.dracodev.net/Projets/agent-manager/issues/13) ·
[am doctor --json #14](https://git.dracodev.net/Projets/agent-manager/issues/14) ·
[am version --json #15](https://git.dracodev.net/Projets/agent-manager/issues/15) ·
[am open #16](https://git.dracodev.net/Projets/agent-manager/issues/16).
Épique de suivi : [issue #1](https://git.dracodev.net/Projets/agent-manager/issues/1) (Phase 0).
### Phase 1 — Cockpit (v0.4.0) — découpage créé le 2026-08-17
Épique : [issue #25](https://git.dracodev.net/Projets/agent-manager/issues/25) · milestone v0.4.0 · 17 issues.
| # | Issue | Effort |
|---|---|---|
| [#26](https://git.dracodev.net/Projets/agent-manager/issues/26) | Recherche fuzzy dans am search | M |
| [#27](https://git.dracodev.net/Projets/agent-manager/issues/27) | Ctrl-R + réexécution dans le REPL | M |
| [#28](https://git.dracodev.net/Projets/agent-manager/issues/28) | am history --rerun N | S |
| [#29](https://git.dracodev.net/Projets/agent-manager/issues/29) | am projects + profils de projet | M |
| [#30](https://git.dracodev.net/Projets/agent-manager/issues/30) | am start contextuel (dépend de #29) | S |
| [#31](https://git.dracodev.net/Projets/agent-manager/issues/31) | am timeline (vue chronologique unifiée) | M |
| [#32](https://git.dracodev.net/Projets/agent-manager/issues/32) | am sessions resume <id> | M |
| [#33](https://git.dracodev.net/Projets/agent-manager/issues/33) | am dashboard (TUI temps réel) | L |
| [#34](https://git.dracodev.net/Projets/agent-manager/issues/34) | Hooks on_install/on_start/on_stop/on_update | M |
| [#35](https://git.dracodev.net/Projets/agent-manager/issues/35) | am watch --restart-on-crash --notify | M |
| [#36](https://git.dracodev.net/Projets/agent-manager/issues/36) | am secret (keyring OS) + injection --env | M |
| [#37](https://git.dracodev.net/Projets/agent-manager/issues/37) | am top + am report (rapport hebdo/mensuel) | M |
| [#38](https://git.dracodev.net/Projets/agent-manager/issues/38) | am log --follow (flux en direct) | S |
| [#39](https://git.dracodev.net/Projets/agent-manager/issues/39) | Favoris, notes et tags personnels | M |
| [#40](https://git.dracodev.net/Projets/agent-manager/issues/40) | Profils d'environnement (am profile dev/prod) | M |
| [#41](https://git.dracodev.net/Projets/agent-manager/issues/41) | Complétions dynamiques + man pages | S |
| [#42](https://git.dracodev.net/Projets/agent-manager/issues/42) | Packaging officiel (winget/scoop/deb/Homebrew) + CI | M |
Critère de sortie du jalon : retrouver n'importe quelle action passée en
moins de 2 secondes.
### Phase 2 — Automatisation (v0.5.0) — découpage créé le 2026-08-17
Épique : [issue #47](https://git.dracodev.net/Projets/agent-manager/issues/47) · milestone v0.5.0 · 27 issues.
| # | 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 |
Critère de sortie du jalon : parc auto-supervisé (crash = redémarrage +
alerte).
### Phase 3 — Plateforme (v1.0) — découpage créé le 2026-08-17
Épique : [issue #48](https://git.dracodev.net/Projets/agent-manager/issues/48) · milestone v1.0 · 5 issues.
| # | Issue | Effort |
|---|---|---|
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
Critère de sortie du jalon : dashboard web complet + API distante.
---
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
La phase 0 est livrée (v0.3.0) ; les découpages des phases 1 (v0.4.0),
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).*
+32
View File
@@ -0,0 +1,32 @@
//! Record build metadata (git commit and branch) at compile time so the
//! 'am version' command can display it, like Nushell's version table.
//!
//! When git is unavailable (e.g. building from a source tarball) the values
//! are simply empty.
use std::process::Command;
fn git(args: &[&str]) -> String {
Command::new("git")
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/heads");
let commit = git(&["rev-parse", "HEAD"]);
let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
// A detached HEAD reports the literal branch name "HEAD".
let branch = if branch == "HEAD" {
String::new()
} else {
branch
};
println!("cargo:rustc-env=AM_GIT_COMMIT={commit}");
println!("cargo:rustc-env=AM_GIT_BRANCH={branch}");
}
+21 -9
View File
@@ -14,7 +14,7 @@ version: "1.0"
settings:
install_dir: null # default: ~/.local/share/agent-manager/agents
log_dir: null # default: ~/.local/state/agent-manager/logs
default_shell: null # default: sh on Unix
default_shell: null # install scripts + shell gateway (default: user's shell)
auto_install_deps: true # offer to install missing dependencies
confirm_before_run: true # confirm before running install scripts
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
@@ -56,6 +56,19 @@ agents:
run: claude
tags: [anthropic, assistant, source-available, mainstream]
- name: deepseek-harness
display_name: "DeepSeek Harness"
description: "CLI officiel DeepSeek (dsh) : boot de profils, gestion des plugins Cordis, orchestration multi-agents, boucles de buts (goals) et interface web. Installez-le pour piloter vos agents depuis am."
category: coding-agent
website: https://github.com/deepseek-ai/deepseek-harness
install:
type: npm
package: "@deepseek-ai/dsh"
dependencies:
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
run: dsh
tags: [deepseek, harness, multi-agent, plugins, web-ui]
- name: hermes-agent
display_name: "Hermes Agent"
description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)."
@@ -328,14 +341,14 @@ agents:
- name: kimi-cli
display_name: "Kimi CLI"
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0)."
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0). Installé via uv (paquet PyPI kimi-cli)."
category: coding-agent
website: https://github.com/MoonshotAI/kimi-cli
install:
type: npm
package: "@moonshotai/kimi-cli"
type: uv
package: kimi-cli
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
- { name: uv, min_version: "0.4.0", install_hint: "https://docs.astral.sh/uv" }
run: kimi
tags: [moonshot, skills, mcp]
@@ -816,10 +829,9 @@ agents:
category: assistant
website: https://github.com/sipeed/picoclaw
install:
type: git
repo: "https://github.com/sipeed/picoclaw"
dependencies:
- { name: go, min_version: "1.21.0", install_hint: "https://go.dev/dl" }
type: binary
repo: sipeed/picoclaw
binary: picoclaw
run: picoclaw
tags: [go, tiny]
+51 -1
View File
@@ -6,7 +6,9 @@ use crate::cli::Cli;
use crate::config::{self, Config, Settings};
use crate::output::Logger;
use crate::state::StateStore;
use anyhow::{Context, Result};
use crate::theme::{self, Theme};
use anyhow::{anyhow, Context, Result};
use std::cell::Cell;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
@@ -19,6 +21,9 @@ pub struct App {
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
/// Active output color theme (switchable at runtime with the REPL
/// 'theme <name>' command).
theme: Cell<&'static Theme>,
}
#[derive(Debug, Clone)]
@@ -78,6 +83,7 @@ impl App {
loaded.config.aliases.clone(),
loaded.config.groups.clone(),
);
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
Ok(Self {
cli,
config: loaded.config,
@@ -86,6 +92,7 @@ impl App {
catalog,
state,
log,
theme: Cell::new(theme),
})
}
@@ -105,6 +112,32 @@ impl App {
!self.cli.no_color && std::io::stdout().is_terminal()
}
pub fn theme(&self) -> &'static Theme {
self.theme.get()
}
/// Switch the active theme (used by the REPL 'theme <name>' command).
pub fn set_theme(&self, theme: &'static Theme) {
self.theme.set(theme);
}
/// Directory of the event journal (the state directory).
pub fn events_dir(&self) -> PathBuf {
self.paths
.state_file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.paths.state_file.clone())
}
/// Append an event to the journal; failures are never fatal (logged at
/// verbose level only).
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:#}"));
}
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes {
@@ -119,6 +152,23 @@ impl App {
}
}
/// Resolve the output color theme: the --theme flag wins, then the
/// configuration entry, then the default theme. An unknown --theme value is
/// an error; an unknown configured value silently falls back to the default.
fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> {
if let Some(name) = flag {
return theme::find(name).ok_or_else(|| {
anyhow!(
"unknown theme '{name}' — available: {}",
theme::names().join(", ")
)
});
}
Ok(configured
.and_then(theme::find)
.unwrap_or_else(theme::default_theme))
}
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
/// mostly used by tests).
pub fn data_dir() -> PathBuf {
+159 -23
View File
@@ -80,35 +80,125 @@ impl Catalog {
}
/// Case-insensitive search over name, display name, description,
/// category, tags and note.
/// category, tags and note — typo-tolerant, ranked by relevance.
pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> {
let kw = keyword.to_lowercase();
self.agents
let mut scored: Vec<(u32, &AgentDef)> = self
.agents
.iter()
.filter(|a| {
if a.hidden {
return false;
}
if let Some(cat) = category {
if a.category.as_deref() != Some(cat) {
return false;
}
}
let hay = format!(
"{} {} {} {} {} {}",
a.name,
a.title(),
a.description.as_deref().unwrap_or(""),
a.category.as_deref().unwrap_or(""),
a.tags.join(" "),
a.note.as_deref().unwrap_or("")
)
.to_lowercase();
hay.contains(&kw)
.filter(|a| !a.hidden)
.filter(|a| category.map_or(true, |c| a.category.as_deref() == Some(c)))
.filter_map(|a| {
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_keyword(&kw, &name)
.saturating_mul(3)
.saturating_add(score_keyword(&kw, &title).saturating_mul(2))
.saturating_add(score_keyword(&kw, &rest));
(score > 0).then_some((score, a))
})
.collect()
.collect();
scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then(a.name.cmp(&b.name)));
scored.into_iter().map(|(_, a)| a).collect()
}
/// "Did you mean" candidates for a keyword with no results: closest
/// agent names and display names by edit distance.
pub fn suggest(&self, keyword: &str) -> Vec<String> {
let kw = keyword.to_lowercase();
let mut out: Vec<(usize, String)> = Vec::new();
for a in self.agents.iter().filter(|a| !a.hidden) {
for field in [&a.name, &a.title().to_string()] {
let f = field.to_lowercase();
if f.is_empty() {
continue;
}
let d = levenshtein(&kw, &f);
if d <= 3 && !out.iter().any(|(_, n)| n == &a.name) {
out.push((d, a.name.clone()));
}
}
}
out.sort_by(|(da, na), (db, nb)| da.cmp(db).then(na.cmp(nb)));
out.truncate(4);
out.into_iter().map(|(_, n)| n).collect()
}
}
/// 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 {
if keyword.is_empty() {
return 0;
}
let tokens: Vec<&str> = keyword.split_whitespace().collect();
let mut total = 0u32;
for token in &tokens {
if token.is_empty() {
continue;
}
let s = score_token(token, hay);
if s == 0 {
return 0;
}
total += s;
}
total
}
fn score_token(token: &str, hay: &str) -> u32 {
if hay.contains(token) {
return 100;
}
if subsequence(token, hay) {
return 40;
}
// Typo tolerance: compare the token with every word of the field.
let mut best = usize::MAX;
for word in hay.split(|c: char| !c.is_alphanumeric()) {
if word.is_empty() {
continue;
}
best = best.min(levenshtein(token, word));
}
match best {
0..=1 => 25,
2 => 15,
3 => 5,
_ => 0,
}
}
/// Do the characters of needle appear in order inside hay?
fn subsequence(needle: &str, hay: &str) -> bool {
let mut it = hay.chars();
needle.chars().all(|c| it.any(|h| h == c))
}
/// Classic Levenshtein distance on characters.
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
impl Catalog {
/// Sorted list of all categories present in the catalog.
pub fn categories(&self) -> Vec<String> {
let mut cats: Vec<String> = self
@@ -156,4 +246,50 @@ mod tests {
let cats = cat.categories();
assert!(cats.contains(&"coding-agent".to_string()));
}
#[test]
fn fuzzy_search_tolerates_missing_separator() {
let cat = catalog();
let hits = cat.search("claude cod", None);
assert!(
hits.iter().any(|a| a.name == "claude-code"),
"expected claude-code in {:?}",
hits.iter().map(|a| &a.name).collect::<Vec<_>>()
);
}
#[test]
fn fuzzy_search_tolerates_typos() {
let cat = catalog();
let hits = cat.search("cludecode", None);
assert!(hits.iter().any(|a| a.name == "claude-code"));
}
#[test]
fn exact_matches_rank_first() {
let cat = catalog();
let hits = cat.search("claude", None);
assert!(!hits.is_empty());
assert_eq!(
hits[0].name, "claude-code",
"exact name match must rank first: {:?}",
hits.iter().map(|a| &a.name).collect::<Vec<_>>()
);
}
#[test]
fn suggest_proposes_close_names() {
let cat = catalog();
let sug = cat.suggest("aiderr");
assert!(sug.contains(&"aider".to_string()), "suggestions: {sug:?}");
assert!(sug.len() <= 4);
}
#[test]
fn levenshtein_distance() {
assert_eq!(levenshtein("aider", "aider"), 0);
assert_eq!(levenshtein("aider", "aiderr"), 1);
assert_eq!(levenshtein("abc", "axc"), 1);
assert_eq!(levenshtein("abc", "xyz"), 3);
}
}
+229 -3
View File
@@ -10,6 +10,9 @@ use std::path::PathBuf;
name = "am",
bin_name = "am",
version,
// The help subcommand is provided by the custom Nushell-style help
// (cli::Command::Help) instead of clap's generated one.
disable_help_subcommand = true,
about = "agent-manager (am) — manage local AI coding agents",
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
@@ -47,6 +50,10 @@ pub struct Cli {
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_color: bool,
/// Color theme for the output (see 'am help theme' for the list)
#[arg(long, global = true, value_name = "THEME")]
pub theme: Option<String>,
#[command(subcommand)]
pub command: Option<Command>,
}
@@ -61,7 +68,49 @@ pub enum Command {
/// Only show agents of this category
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
/// Only show running agents
#[arg(long)]
running: bool,
/// Sort by name, version or status (default: name)
#[arg(long, value_name = "KEY")]
sort: Option<String>,
},
/// Tail the log file of an agent (--follow for a live view)
Logs {
/// Agent name or alias
agent: String,
/// Number of lines to show (default: 20)
#[arg(long, value_name = "N", default_value = "20")]
lines: usize,
/// Follow the log as it grows
#[arg(long)]
follow: bool,
},
/// Open an agent's installation directory in the file manager
Open {
/// Agent name or alias
agent: String,
},
/// Supervise an agent and optionally restart it on crash
Watch {
/// Agent name or alias
agent: String,
/// Restart the agent automatically when it dies (with backoff)
#[arg(long)]
restart: bool,
/// Desktop notification on every restart
#[arg(long)]
notify: bool,
/// Check interval in seconds (default: 5)
#[arg(long, value_name = "SECS", default_value = "5")]
interval: u64,
},
/// Manage command aliases (cc -> claude-code)
#[command(subcommand)]
Alias(AliasCmd),
/// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)]
Secret(SecretCmd),
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -91,6 +140,85 @@ pub enum Command {
/// Agent name or alias (omit to show all installed agents)
agent: Option<String>,
},
/// List the sessions of every agent (and of the interactive shell)
Sessions {
/// Only sessions of this agent
agent: Option<String>,
/// Only sessions of this project
#[arg(long, value_name = "PROJECT")]
project: Option<String>,
/// Only sessions with this status (running, stopped, failed, interrupted)
#[arg(long, value_name = "STATUS")]
status: Option<String>,
/// Show one session in detail (summary + log excerpt)
#[arg(long, value_name = "ID")]
show: Option<String>,
/// Resume a finished session: relaunch the agent with its recorded arguments
#[arg(long, value_name = "ID")]
resume: Option<String>,
},
/// Show usage statistics computed from the event journal
Stats {
/// Agent name or alias (omit for the global view)
agent: Option<String>,
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
},
/// Show the most used agents (top 10)
Top {
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
},
/// Write a markdown activity report
Report {
/// Cover the last 7 days
#[arg(long, conflicts_with = "last_month")]
last_week: bool,
/// Cover the last 30 days
#[arg(long)]
last_month: bool,
/// Output file (default: report-YYYYMMDD.md in the state directory)
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Show which agent works on which project
Projects {
/// Project name (omit to list every project)
name: Option<String>,
},
/// One chronological view of every activity (journal + REPL history)
Timeline {
/// Only activity of this agent
agent: Option<String>,
/// Only activity of this project
#[arg(long, value_name = "PROJECT")]
project: Option<String>,
/// Only activity at or after this date (YYYY-MM-DD or RFC 3339)
#[arg(long, value_name = "DATE")]
since: Option<String>,
/// Maximum number of entries (default: 50)
#[arg(long, value_name = "N", default_value = "50")]
limit: usize,
},
/// Read the event journal: everything am did
Log {
/// Only events of this agent
agent: Option<String>,
/// Only these event kinds (comma separated: start,stop,run,install,...)
#[arg(long, value_name = "KINDS")]
kind: Option<String>,
/// Only events at or after this date (YYYY-MM-DD or RFC 3339)
#[arg(long, value_name = "DATE")]
since: Option<String>,
/// Maximum number of events (default: 50)
#[arg(long, value_name = "N", default_value = "50")]
limit: usize,
/// Follow the journal as new events arrive
#[arg(long)]
follow: bool,
},
/// Install an agent and its dependencies
Install {
/// Agent name or alias
@@ -102,7 +230,7 @@ pub enum Command {
#[arg(long)]
force: bool,
},
/// Uninstall an agent and clean its files
/// Uninstall an agent (managed or external) and clean its files
Uninstall {
/// Agent name or alias
agent: String,
@@ -126,11 +254,51 @@ pub enum Command {
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
},
/// Search the command history (am and shell commands)
History {
/// Only commands of this kind (am or shell)
#[arg(long, value_name = "KIND")]
kind: Option<String>,
/// Only commands run in this directory
#[arg(long, value_name = "DIR")]
cwd: Option<String>,
/// Full-text search in the command line
#[arg(long, value_name = "TEXT")]
search: Option<String>,
/// Only failed commands
#[arg(long)]
failed: bool,
/// Only commands of this session
#[arg(long, value_name = "ID")]
session: Option<String>,
/// Maximum number of entries (default: 100)
#[arg(long, value_name = "N", default_value = "100")]
limit: usize,
/// Re-run the Nth most recent entry (with confirmation)
#[arg(long, value_name = "N")]
rerun: Option<usize>,
},
/// Show detailed information about one agent
Info {
/// Agent name or alias
agent: String,
},
/// Generate a local agent-manager.yaml for the current directory
Init {
/// Overwrite an existing agent-manager.yaml
#[arg(long)]
force: bool,
},
/// Display help: the overview, a command, the command list, or a search
Help {
/// Command name (also accepts an agent name or alias); "commands" lists every command
command: Option<String>,
/// Search through all help commands table
#[arg(long, value_name = "TEXT")]
find: Option<String>,
},
/// Display version and build information
Version,
/// Manage the configuration file
#[command(subcommand)]
Config(ConfigCmd),
@@ -163,6 +331,12 @@ pub enum Command {
#[arg(long, value_name = "FILE")]
to: Option<PathBuf>,
},
/// Remove agent-manager and everything it created from this machine
///
/// Stops every managed background agent, then deletes the data, state and
/// configuration directories, and finally the executable itself. Combine
/// with --yes for a fully automated one-liner.
SelfUninstall,
/// Export the configuration and installation state (backup)
Export {
/// Output file (default: am-export.json)
@@ -179,8 +353,8 @@ pub enum Command {
/// Arguments shared by start and restart.
#[derive(Args, Debug, Clone, Default)]
pub struct StartArgs {
/// Agent name, alias, or group:<name>
pub agent: String,
/// Agent name, alias, or group:<name> (defaults to the project's default agent)
pub agent: Option<String>,
/// Run detached in the background; output goes to the agent log file
#[arg(short = 'b', long, conflicts_with = "foreground", action = ArgAction::SetTrue)]
pub background: bool,
@@ -198,6 +372,51 @@ pub struct StartArgs {
pub notify: bool,
}
/// Secret management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum SecretCmd {
/// Store a secret for an agent
Set {
/// Secret name (the environment variable name)
name: String,
/// Agent it belongs to
#[arg(long, value_name = "AGENT")]
agent: String,
/// Secret value (prefer --value over shell history; never logged)
#[arg(long, value_name = "VALUE")]
value: String,
},
/// Remove a secret
Unset {
/// Secret name
name: String,
/// Agent it belongs to
#[arg(long, value_name = "AGENT")]
agent: String,
},
/// List stored secret names (values are never shown)
List,
}
/// Alias management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum AliasCmd {
/// Add an alias (am alias add cc claude-code)
Add {
/// Short name of the alias
name: String,
/// Agent name (or another alias) it points to
target: String,
},
/// Remove an alias
Remove {
/// Alias to remove
name: String,
},
/// List every alias
List,
}
/// Configuration management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigCmd {
@@ -214,4 +433,11 @@ pub enum ConfigCmd {
/// YAML (or JSON) file defining agents to include
file: PathBuf,
},
/// Set one configuration key without an editor (config set settings.default_shell pwsh)
Set {
/// Dotted key path, e.g. settings.default_shell
key: String,
/// New value (booleans and integers keep their type)
value: String,
},
}
+103
View File
@@ -0,0 +1,103 @@
//! alias: manage command aliases (cc -> claude-code) in the user config.
use super::*;
use crate::cli::AliasCmd;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
/// Ensure the user config file exists and return its path.
fn user_config(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join(crate::config::CONFIG_FILE_NAME);
if !path.exists() {
std::fs::write(&path, concat!("version: \"1.0\"\n", "agents: []\n"))
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(path)
}
/// Read the aliases map from the user config file.
fn read_aliases(app: &App) -> Result<(PathBuf, serde_yaml::Mapping)> {
let path = user_config(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let mapping = value
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
let aliases = mapping
.entry(serde_yaml::Value::String("aliases".to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()))
.as_mapping()
.cloned()
.unwrap_or_default();
Ok((path, aliases))
}
fn write_aliases(app: &App, path: &std::path::Path, aliases: serde_yaml::Mapping) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
if let Some(mapping) = value.as_mapping_mut() {
mapping.insert(
serde_yaml::Value::String("aliases".to_string()),
serde_yaml::Value::Mapping(aliases),
);
}
std::fs::write(path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", path.display()))
}
pub fn run(app: &App, sub: &AliasCmd) -> Result<i32> {
match sub {
AliasCmd::Add { name, target } => {
// The target must be a known agent (or another alias).
require_agent(app, target)?;
let (path, mut aliases) = read_aliases(app)?;
aliases.insert(
serde_yaml::Value::String(name.clone()),
serde_yaml::Value::String(target.clone()),
);
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' -> '{target}'"));
Ok(0)
}
AliasCmd::Remove { name } => {
let (path, mut aliases) = read_aliases(app)?;
let removed = aliases
.remove(&serde_yaml::Value::String(name.clone()))
.is_some();
if removed {
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' removed"));
} else {
app.log.info(&format!("alias '{name}' does not exist"));
}
Ok(0)
}
AliasCmd::List => {
if app.json() {
print_json(&app.config.aliases);
return Ok(0);
}
if app.config.aliases.is_empty() {
app.log.info("no aliases defined — 'am alias add cc claude-code'");
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["ALIAS", "TARGET"]);
for (k, v) in &app.config.aliases {
table.row(vec![k.clone(), v.clone()]);
}
print!("{}", table.render());
Ok(0)
}
}
}
+55
View File
@@ -5,6 +5,7 @@ use crate::cli::ConfigCmd;
use crate::config;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use clap::Parser;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -94,9 +95,63 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
Ok(status.code().unwrap_or(1))
}
ConfigCmd::Add { file } => add_file(app, file),
ConfigCmd::Set { key, value } => set_value(app, key, value),
}
}
/// config set: set one dotted key (e.g. settings.default_shell) in the user
/// configuration file, then validate the result.
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
let path = user_config_path(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
return Err(anyhow!("invalid key '{key}' — expected a dotted path like settings.default_shell"));
}
let parsed = parse_scalar(value);
let mut node = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
for part in &parts[..parts.len() - 1] {
let entry = node
.entry(serde_yaml::Value::String(part.to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
node = entry
.as_mapping_mut()
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
}
node.insert(
serde_yaml::Value::String(parts.last().unwrap().to_string()),
parsed,
);
std::fs::write(&path, serde_yaml::to_string(&root)?)
.with_context(|| format!("cannot write {}", path.display()))?;
// Validate the new configuration end to end.
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
config::load(&cli).map_err(|e| {
anyhow!("the new value makes the configuration invalid: {e:#}")
})?;
app.log.success(&format!("{key} = {value}"));
Ok(0)
}
/// Parse a scalar: booleans and integers keep their type, everything else
/// stays a string.
fn parse_scalar(value: &str) -> serde_yaml::Value {
if let Ok(b) = value.parse::<bool>() {
return serde_yaml::Value::Bool(b);
}
if let Ok(i) = value.parse::<i64>() {
return serde_yaml::Value::Number(i.into());
}
serde_yaml::Value::String(value.to_string())
}
fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
app.paths
.config_dir
+23 -7
View File
@@ -2,7 +2,8 @@
use super::*;
use crate::doctor::{self, Status};
use crate::output::{print_json, status_style, Table};
use crate::events::{Event, EventKind};
use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
use anyhow::Result;
@@ -22,21 +23,36 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
// The doctor report probes real tools, even under --dry-run.
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
if !app.dry_run() {
app.emit(
&Event::now(EventKind::Doctor)
.with_exit_code(if report.ok() { 0 } else { 1 }),
);
}
if app.json() {
print_json(&report.checks);
return Ok(if report.ok() { 0 } else { 1 });
}
let color = app.color();
let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]);
let theme = app.theme();
let mut table_rows = Vec::new();
for c in &report.checks {
let status = match c.status {
Status::Ok => status_style("ok", color),
Status::Warn => status_style("warn", color),
Status::Error => status_style("error", color),
Status::Ok => status_style("ok", theme, color),
Status::Warn => status_style("warn", theme, color),
Status::Error => status_style("error", theme, color),
};
table.row(vec![c.name.clone(), status, c.detail.clone()]);
table_rows.push(vec![c.name.clone(), status, c.detail.clone()]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["CHECK", "STATUS", "DETAIL"],
table_rows,
theme,
color,
)
);
if report.problems() > 0 {
app.log
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
+1
View File
@@ -77,6 +77,7 @@ pub fn import_cmd(app: &App, file: &Path) -> Result<i32> {
groups: Default::default(),
include: vec![],
agents: export.config.agents.clone(),
projects: Default::default(),
};
std::fs::write(&dest, serde_yaml::to_string(&cfg)?)
.with_context(|| format!("cannot write {}", dest.display()))?;
+12
View File
@@ -0,0 +1,12 @@
//! help: Nushell-style help — the overview, one command, the command list,
//! or a keyword search through it.
use super::*;
pub fn run(app: &App, command: Option<&str>, find: Option<&str>) -> Result<i32> {
if let Some(text) = find {
crate::help::print_find(app, text);
return Ok(0);
}
crate::help::print_topic(app, command)
}
+138
View File
@@ -0,0 +1,138 @@
//! history: search and reuse the structured command history.
use super::*;
use crate::history::{self, HistoryEntry};
use crate::output::print_json;
use anyhow::{anyhow, Result};
use clap::Parser;
/// Re-run the Nth most recent entry: shell commands go through the
/// active shell, am commands are parsed and dispatched like the REPL does.
fn rerun_entry(app: &App, n: usize) -> Result<i32> {
if n == 0 {
return Err(anyhow!("invalid entry number 0 — am history numbers start at 1"));
}
let entries = history::read_all(app, n);
let entry = entries.get(n - 1).cloned().ok_or_else(|| {
anyhow!("history entry {n} not found — 'am history' lists them")
})?;
let question = format!("re-run: {} [y/N]", entry.cmd);
if !app.confirm(&question)? {
app.log.info("cancelled");
return Ok(0);
}
let cmd = entry.cmd.strip_prefix('!').unwrap_or(&entry.cmd);
if entry.kind == "shell" {
let session = crate::shell::ShellSession::start(&app.config);
return Ok(session.run(cmd)?);
}
let tokens = shell_words::split(cmd)
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
if tokens.is_empty() {
return Ok(0);
}
let cli = crate::cli::Cli::try_parse_from(std::iter::once("am".to_string()).chain(tokens))
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
match &cli.command {
Some(cmd) => execute_command(app, cmd),
None => {
app.log.info("nothing to re-run (no subcommand)");
Ok(0)
}
}
}
/// Filter history entries; returns them most recent first.
pub fn filter(
app: &App,
kind: Option<&str>,
cwd: Option<&str>,
search: Option<&str>,
failed: bool,
session: Option<&str>,
limit: usize,
) -> Vec<(String, HistoryEntry)> {
let _ = session; // session filtering happens by reading one file directly
let mut out: Vec<(String, HistoryEntry)> = Vec::new();
let dir = history::dir(app);
for f in history::files(&dir).iter().rev() {
let sid = history::session_of_file(f).unwrap_or_default();
if let Some(s) = session {
if sid != s {
continue;
}
}
let Ok(text) = std::fs::read_to_string(f) else {
continue;
};
let mut entries: Vec<HistoryEntry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
.collect();
entries.reverse();
for e in entries {
if kind.map_or(true, |k| e.kind == k)
&& cwd.map_or(true, |c| e.cwd.as_deref() == Some(c))
&& search.map_or(true, |s| e.cmd.to_lowercase().contains(&s.to_lowercase()))
&& (!failed || e.exit_code.is_some())
{
out.push((sid.clone(), e));
if limit > 0 && out.len() >= limit {
return out;
}
}
}
}
out
}
pub fn run(
app: &App,
kind: Option<&str>,
cwd: Option<&str>,
search: Option<&str>,
failed: bool,
session: Option<&str>,
limit: usize,
rerun: Option<usize>,
) -> Result<i32> {
history::migrate(app)?;
if let Some(n) = rerun {
return rerun_entry(app, n);
}
let rows = filter(app, kind, cwd, search, failed, session, limit);
if app.json() {
let payload: Vec<serde_json::Value> = rows
.iter()
.enumerate()
.map(|(i, (sid, e))| {
serde_json::json!({
"n": rows.len() - i,
"ts": e.ts,
"cmd": e.cmd,
"kind": e.kind,
"cwd": e.cwd,
"exit_code": e.exit_code,
"duration_ms": e.duration_ms,
"session": sid,
})
})
.collect();
print_json(&payload);
return Ok(0);
}
for (i, (_sid, e)) in rows.iter().enumerate() {
let n = rows.len() - i;
let ts = e.ts.get(0..16).unwrap_or(&e.ts);
let flag = match e.exit_code {
Some(_) => "✗".to_string(),
None => " ".to_string(),
};
println!("{n:>4} {flag} {ts} {}", e.cmd);
}
if rows.is_empty() {
app.log.info("no matching history — run 'am' and type some commands");
}
Ok(0)
}
+134 -48
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::output::print_json;
use colored::Colorize;
use serde::Serialize;
#[derive(Serialize)]
@@ -101,51 +102,102 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
return Ok(0);
}
println!("{}", agent.title());
if agent.name != agent.title() {
println!(" slug: {}", agent.name);
}
if let Some(d) = &agent.description {
println!(" description: {d}");
}
let color = app.color();
let dim = |s: &str| -> String {
if color {
s.dimmed().to_string()
} else {
s.to_string()
}
};
let bold = |s: &str| -> String {
if color {
s.bold().to_string()
} else {
s.to_string()
}
};
// --- Header -------------------------------------------------------------
println!("{}", bold(agent.title()));
let mut sub: Vec<String> = vec![agent.name.clone()];
if let Some(c) = &agent.category {
println!(" category: {c}");
sub.push(c.clone());
}
println!(" {}", dim(&sub.join(" · ")));
if let Some(d) = &agent.description {
println!();
println!(" {d}");
}
if let Some(w) = &agent.website {
println!(" website: {w}");
}
if !agent.tags.is_empty() {
println!(" tags: {}", agent.tags.join(", "));
}
if !agent.platforms.is_empty() {
println!(" platforms: {}", agent.platforms.join(", "));
println!();
println!(" {} {}", dim("website"), w);
}
println!();
// Status block
let status = if running {
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
// --- Status --------------------------------------------------------------
let external = external_path(agent);
let (status_word, detail): (&str, String) = if running {
let pid = entry.as_ref().and_then(|e| e.pid).unwrap_or(0);
("running", format!(" (pid {pid})"))
} else if entry.is_some() {
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
let v = entry
.as_ref()
.and_then(|e| e.version.clone())
.map(|v| format!(" — v{v}"))
.unwrap_or_default();
("installed", v)
} else if !agent.installable {
"not installable locally".to_string()
} else if external_path(agent).is_some() {
"found on PATH (external — not managed)".to_string()
("not-installable", String::new())
} else if external.is_some() {
("external", " (found on PATH — not managed)".to_string())
} else {
"not installed".to_string()
("not-installed", String::new())
};
println!(" status: {status}");
let word = crate::output::status_style(status_word, app.theme(), color);
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
// Version: recorded for managed installs, detected for external ones.
let version_display: Option<String> = entry
.as_ref()
.and_then(|e| e.version.clone())
.or_else(|| external.as_ref().and_then(|p| crate::probe::detect_external_version(p)));
if let Some(v) = version_display {
println!(" {} {}", dim("● version"), v);
}
if let Some(t) = entry.as_ref().map(|e| e.installed_at.clone()) {
println!(" {} {}", dim("● installed"), t);
}
if let Some(t) = entry.as_ref().and_then(|e| e.updated_at.clone()) {
println!(" {} {}", dim("● updated"), t);
}
if let Some(p) = entry
.as_ref()
.map(|e| e.install_dir.clone())
.or_else(|| external.clone().map(|p| p.display().to_string()))
{
println!(" {} {}", dim("● path"), p);
}
if let Some(e) = &entry {
if let Some(bin) = e.bins.first() {
println!(" {} {}", dim("● bin"), bin);
}
}
// --- How to run it -------------------------------------------------------
if let Some(run) = &agent.run {
let args = if agent.args.is_empty() {
String::new()
} else {
format!(" {}", agent.args.join(" "))
};
println!(" run: {run}{args}");
println!();
println!(" {} {}", dim("▶ run"), format!("{run}{args}"));
}
if !agent.env.is_empty() {
println!(
" env: {}",
" {} {}",
dim("⚙ env"),
agent
.env
.iter()
@@ -155,38 +207,72 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
);
}
// --- Installation ---------------------------------------------------------
if let Some(spec) = &agent.install {
println!(" install methods:");
println!();
println!(" {}", bold("install"));
for (i, m) in spec.all_methods().iter().enumerate() {
let note = m
.note
.as_deref()
.map(|n| format!(" ({n})"))
.unwrap_or_default();
println!(" {}) {}{}", i + 1, describe_method(m), note);
println!(" {}) {}", i + 1, describe_method(m));
if let Some(n) = &m.note {
println!(" {}", dim(n));
}
}
if !spec.post_install.is_empty() {
println!(" post-install: {}", spec.post_install.join(" && "));
println!(
" {}",
dim(&format!("post-install: {}", spec.post_install.join(" && ")))
);
}
}
// --- Dependencies, checked live ------------------------------------------
if !agent.dependencies.is_empty() {
println!(" dependencies:");
for d in &agent.dependencies {
let min = d
.min_version
.as_deref()
.map(|m| format!(" >= {m}"))
.unwrap_or_default();
let hint = d
.install_hint
.as_deref()
.map(|h| format!(" — {h}"))
.unwrap_or_default();
println!(" - {}{}{}", d.name, min, hint);
println!();
println!(" {}", bold("dependencies"));
let runner = crate::runner::SystemRunner::new(false, app.cli.verbose, &app.log);
let checks = crate::deps::check_agent(agent, &runner);
for c in &checks {
let mark = if c.ok { "✓" } else { "✗" };
let mark = if color {
if c.ok {
mark.green().to_string()
} else {
mark.red().to_string()
}
} else {
mark.to_string()
};
let mut line = format!(" {mark} {}", c.name);
if let Some(v) = &c.version {
line.push_str(&format!(" {v}"));
}
if let Some(min) = &c.min {
line.push_str(&format!(" (min {min})"));
}
if !c.ok {
if let Some(hint) = agent
.dependencies
.iter()
.find(|d| d.name == c.name)
.and_then(|d| d.install_hint.clone())
{
line.push_str(&format!(" — {hint}"));
} else if !c.detail.is_empty() && c.detail != "not found on PATH" {
line.push_str(&format!(" — {}", c.detail));
}
}
println!("{line}");
}
}
// --- Tags and note ---------------------------------------------------------
if !agent.tags.is_empty() {
println!();
println!(" {}", dim(&format!("tags {}", agent.tags.join(" · "))));
}
if let Some(n) = &agent.note {
println!(" note: {n}");
println!();
println!(" {}", dim(&format!("note {n}")));
}
Ok(0)
}
+70
View File
@@ -0,0 +1,70 @@
//! init: generate a local agent-manager.yaml for the current directory,
//! based on the detected technology stack.
use super::*;
use crate::context;
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> {
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
if target.exists() && !force {
return Err(anyhow!(
"{} already exists — use --force to overwrite it",
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()
} else {
ctx.stack.join(", ")
};
let content = 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(),
);
std::fs::write(&target, content)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
pub fn run(app: &App, force: bool) -> Result<i32> {
if app.dry_run() {
app.log.dry(format!(
"would generate {} in the current directory",
crate::config::LOCAL_CONFIG_NAME
));
return Ok(0);
}
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(", ")
}
));
app.log.info(&format!(
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
));
Ok(0)
}
+11
View File
@@ -2,6 +2,7 @@
//! installer, and record the installation in the state database.
use super::*;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::SystemRunner;
use anyhow::{anyhow, bail, Result};
@@ -137,6 +138,16 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
let entry = installers::make_entry(agent, method, &outcome, app);
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&entry)?;
app.emit(
&Event::now(EventKind::Install)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
crate::hooks::run_hooks(
app,
"on_install",
&std::env::current_dir().unwrap_or_default(),
);
app.log.success(&format!(
"installed {} (v{version}) in {}",
agent.title(),
+31 -7
View File
@@ -2,7 +2,7 @@
//! the whole catalog with --all.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::output::{boxed_table, print_json, status_style};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
@@ -25,7 +25,13 @@ struct AgentRow {
}
/// run: installed agents only, unless all is set (whole catalog).
pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
pub fn run(
app: &App,
all: bool,
category: Option<&str>,
running: bool,
sort: Option<&str>,
) -> Result<i32> {
let entries = app.state.all().unwrap_or_default();
let probe = crate::probe::detect_externals(app);
let mut rows: Vec<AgentRow> = Vec::new();
@@ -43,24 +49,42 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
if !all {
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
}
rows.sort_by(|a, b| a.name.cmp(&b.name));
if running {
rows.retain(|r| r.status == "running");
}
match sort {
Some("version") => rows.sort_by(|a, b| b.version.cmp(&a.version)),
Some("status") => rows.sort_by(|a, b| a.status.cmp(&b.status)),
Some(other) => bail!("invalid sort key '{other}' — expected name, version or status"),
None => rows.sort_by(|a, b| a.name.cmp(&b.name)),
}
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
let theme = app.theme();
let mut table_rows = Vec::new();
for r in &rows {
table.row(vec![
table_rows.push(vec![
r.display_name.clone(),
r.name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
status_style(&r.status, theme, color),
r.source.clone(),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"],
table_rows,
theme,
color,
)
);
if rows.is_empty() {
app.log.info(if all {
"no agents found"
+160
View File
@@ -0,0 +1,160 @@
//! log: a filtered view of the event journal — everything am did.
use super::*;
use crate::events::{self, Event};
use crate::output::print_json;
use serde::Serialize;
/// Filter journal events: agent, kind list, since date, and a max count.
/// Returns events in reverse chronological order (most recent first).
pub fn filter_events(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<Vec<Event>> {
let mut events = events::read_events(&app.events_dir(), 0);
if let Some(a) = agent {
events.retain(|e| e.agent.as_deref() == Some(a));
}
if let Some(list) = kinds {
let wanted: Vec<&str> = list.split(',').map(|s| s.trim()).collect();
events.retain(|e| wanted.iter().any(|w| e.kind.as_str() == *w));
}
if let Some(s) = since {
let cut = parse_since(s)?;
events.retain(|e| {
chrono::DateTime::parse_from_rfc3339(&e.ts)
.map(|t| t >= cut)
.unwrap_or(false)
});
}
events.reverse(); // most recent first
events.truncate(limit);
Ok(events)
}
/// Live view: print matching events as they arrive (issue #38).
fn follow_loop(app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>) -> Result<i32> {
app.log.info("following the journal (Ctrl-C to stop)");
let mut seen: usize = 0;
loop {
let events = filter_events(app, agent, kinds, since, 200)?;
let mut rendered = String::new();
for e in events.iter().rev() {
rendered.push_str(&e.ts);
rendered.push(' ');
rendered.push_str(e.kind.as_str());
if let Some(a) = &e.agent {
rendered.push(' ');
rendered.push_str(a);
}
rendered.push('\n');
}
let bytes = rendered.as_bytes();
if bytes.len() > seen {
print!("{}", text_after(&rendered, seen));
use std::io::Write;
let _ = std::io::stdout().flush();
}
seen = bytes.len();
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(t);
}
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| anyhow!("invalid date '{s}' — expected YYYY-MM-DD or an RFC 3339 timestamp"))?
.and_hms_opt(0, 0, 0)
.map(|d| chrono::DateTime::from_naive_utc_and_offset(d, chrono::FixedOffset::east_opt(0).unwrap()))
.ok_or_else(|| anyhow!("invalid date '{s}'"))
}
#[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>,
}
impl From<&Event> for LogRow {
fn from(e: &Event) -> Self {
LogRow {
ts: e.ts.clone(),
kind: e.kind.as_str().to_string(),
agent: e.agent.clone(),
pid: e.pid,
exit_code: e.exit_code,
duration_s: e.duration_s,
session: e.session.clone(),
}
}
}
/// Chars of text after byte offset seen (UTF-8 safe).
pub fn text_after(text: &str, seen: usize) -> String {
text.char_indices()
.filter(|(i, _)| *i >= seen)
.map(|(_, c)| c)
.collect()
}
pub fn run(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
follow: bool,
) -> Result<i32> {
if follow {
return follow_loop(app, agent, kinds, since);
}
let events = filter_events(app, agent, kinds, since, limit)?;
if app.json() {
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
print_json(&rows);
return Ok(0);
}
for e in &events {
let mut parts = vec![e.ts.clone(), e.kind.as_str().to_string()];
if let Some(a) = &e.agent {
parts.push(a.clone());
}
if let Some(p) = e.pid {
parts.push(format!("pid={p}"));
}
if let Some(c) = e.exit_code {
parts.push(format!("exit={c}"));
}
if let Some(d) = e.duration_s {
parts.push(format!("dur={d}s"));
}
println!("{}", parts.join(" "));
}
if events.is_empty() {
app.log.info("no matching events in the journal");
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_since_accepts_date_and_timestamp() {
let d = parse_since("2026-08-01").unwrap();
assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-08-01");
assert!(parse_since("2026-08-01T00:00:00Z").is_ok());
assert!(parse_since("hier").is_err());
}
}
+56
View File
@@ -0,0 +1,56 @@
//! logs: tail the log file of an agent (--follow for a live view).
use super::*;
use crate::output::print_json;
use anyhow::{bail, Result};
use std::path::PathBuf;
/// Resolve the log path of an agent (validates the agent first).
pub fn log_path(app: &App, agent_name: &str) -> Result<PathBuf> {
let agent = require_agent(app, agent_name)?;
Ok(crate::process::agent_log_path(app, &agent.name))
}
pub fn run(app: &App, agent: &str, lines: usize, follow: bool) -> Result<i32> {
let path = log_path(app, agent)?;
if !path.exists() {
bail!(
"no log yet for '{}' — start it with 'am start {} --background'",
agent,
agent
);
}
if app.json() {
let tail = crate::process::last_lines(&path, lines);
print_json(&serde_json::json!({
"agent": agent,
"log": path.display().to_string(),
"tail": tail,
}));
return Ok(0);
}
if !follow {
print!("{}", crate::process::last_lines(&path, lines));
return Ok(0);
}
// Live follow: print the initial tail, then new bytes as they arrive.
let mut seen: usize = std::fs::metadata(&path).map(|m| m.len() as usize).unwrap_or(0);
loop {
if let Ok(text) = std::fs::read_to_string(&path) {
let bytes = text.as_bytes();
if bytes.len() > seen {
// Slicer sans risque de couper un caractere UTF-8.
let tail: String = text
.char_indices()
.filter(|(i, _)| *i >= seen)
.map(|(_, c)| c)
.collect();
print!("{tail}");
use std::io::Write;
let _ = std::io::stdout().flush();
}
seen = bytes.len();
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
+144 -1
View File
@@ -1,18 +1,33 @@
//! Command implementations and dispatch.
pub mod alias_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod help_cmd;
pub mod history_cmd;
pub mod info_cmd;
pub mod init_cmd;
pub mod install_cmd;
pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod open_cmd;
pub mod projects_cmd;
pub mod run_cmd;
pub mod search_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod timeline_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod watch_cmd;
pub mod version_cmd;
use crate::app::App;
use crate::cli::Command;
@@ -32,7 +47,26 @@ pub fn execute(app: &App) -> Result<i32> {
/// Execute one parsed command (also used by the interactive shell).
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
match cmd {
Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()),
Command::List {
all,
category,
running,
sort,
} => list_cmd::run(app, *all, category.as_deref(), *running, sort.as_deref()),
Command::Logs {
agent,
lines,
follow,
} => logs_cmd::run(app, agent, *lines, *follow),
Command::Open { agent } => open_cmd::run(app, agent),
Command::Watch {
agent,
restart,
notify,
interval,
} => watch_cmd::run(app, agent, *restart, *notify, *interval),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -45,6 +79,41 @@ 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::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report {
last_week,
last_month,
output,
} => stats_cmd::run_report(app, *last_week, *last_month, output.as_deref()),
Command::Log {
agent,
kind,
since,
limit,
follow,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit, *follow),
Command::Projects { name } => projects_cmd::run(app, name.as_deref()),
Command::Timeline {
agent,
project,
since,
limit,
} => timeline_cmd::run(app, agent.as_deref(), project.as_deref(), since.as_deref(), *limit),
Command::Sessions {
agent,
project,
status,
show,
resume,
} => sessions_cmd::run(
app,
agent.as_deref(),
project.as_deref(),
status.as_deref(),
show.as_deref(),
resume.as_deref(),
),
Command::Install {
agent,
method,
@@ -53,12 +122,34 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
Command::History {
kind,
cwd,
search,
failed,
session,
limit,
rerun,
} => history_cmd::run(
app,
kind.as_deref(),
cwd.as_deref(),
search.as_deref(),
*failed,
session.as_deref(),
*limit,
*rerun,
),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force } => init_cmd::run(app, *force),
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app),
Command::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::Completion { shell } => completion_cmd::run(*shell),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
Command::Export { output } => export_import::export_cmd(app, output),
Command::Import { file } => export_import::import_cmd(app, file),
}
@@ -113,9 +204,31 @@ pub fn resolve_exec(
args.extend(extra_args.iter().cloned());
let mut env = agent.env.clone();
env.extend(extra_env.clone());
let warnings =
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
for w in warnings {
app.log.warn(&w);
}
let (program, args) = route_cmd_script(program, args);
Ok(ResolvedExec { program, args, env })
}
/// On Windows, .cmd/.bat shims (git installs without a build step) must be
/// executed through cmd.exe — spawning them directly fails.
pub fn route_cmd_script(program: String, args: Vec<String>) -> (String, Vec<String>) {
let lower = program.to_lowercase();
if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) {
let shell = std::env::var_os("COMSPEC")
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "cmd.exe".to_string());
let mut full = vec!["/C".to_string(), program];
full.extend(args);
(shell, full)
} else {
(program, args)
}
}
/// Parse --env KEY=VALUE entries.
pub fn parse_env_list(items: &[String]) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
@@ -211,3 +324,33 @@ pub fn select_method<'a>(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_cmd_scripts_through_comspec() {
let (program, args) = route_cmd_script(
"C:/x/agent.cmd".to_string(),
vec!["--flag".to_string()],
);
let lower = program.to_lowercase();
if cfg!(windows) {
assert!(lower.ends_with("cmd.exe"), "{program}");
assert_eq!(args[0], "/C");
assert_eq!(args[1], "C:/x/agent.cmd");
assert_eq!(args[2], "--flag");
} else {
assert_eq!(program, "C:/x/agent.cmd");
assert_eq!(args, vec!["--flag".to_string()]);
}
}
#[test]
fn leaves_regular_programs_untouched() {
let (program, args) = route_cmd_script("node".to_string(), vec!["-v".to_string()]);
assert_eq!(program, "node");
assert_eq!(args, vec!["-v".to_string()]);
}
}
+42
View File
@@ -0,0 +1,42 @@
//! open: open an agent's installation directory in the file manager.
use super::*;
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
/// Resolve the directory to open: the managed install dir, or the parent of
/// the detected external binary.
pub fn resolve_target(app: &App, agent_name: &str) -> Result<PathBuf> {
let agent = require_agent(app, agent_name)?;
if let Some(entry) = app.state.get(&agent.name).ok().flatten() {
return Ok(PathBuf::from(&entry.install_dir));
}
let probe = crate::probe::detect_externals(app);
if let Some(bin) = probe.path_of(&agent.name) {
return Ok(bin
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| bin.clone()));
}
bail!("agent '{}' is not installed — run 'am install {}' first", agent_name, agent_name);
}
pub fn run(app: &App, agent: &str) -> Result<i32> {
let target = resolve_target(app, agent)?;
if app.dry_run() {
app.log.dry(format!("would open {}", target.display()));
return Ok(0);
}
if !target.exists() {
bail!("directory {} does not exist", target.display());
}
#[cfg(windows)]
let opened = std::process::Command::new("explorer").arg(&target).status();
#[cfg(target_os = "macos")]
let opened = std::process::Command::new("open").arg(&target).status();
#[cfg(all(unix, not(target_os = "macos")))]
let opened = std::process::Command::new("xdg-open").arg(&target).status();
opened
.with_context(|| format!("cannot open {}", target.display()))?;
Ok(0)
}
+92
View File
@@ -0,0 +1,92 @@
//! projects: which agent works on which project.
use super::*;
use crate::output::{print_json, Table};
use crate::projects::{self, ProjectSummary};
pub fn run(app: &App, name: Option<&str>) -> Result<i32> {
let summaries = projects::aggregate(app);
if let Some(n) = name {
return show_one(app, n, &summaries);
}
if app.json() {
print_json(&summaries);
return Ok(0);
}
let mut table = Table::new(vec!["PROJECT", "AGENTS", "SESSIONS", "DURATION", "LAST"]);
for s in &summaries {
table.row(vec![
short_path(&s.root),
s.dominant
.clone()
.unwrap_or_else(|| "—".to_string()),
s.sessions.to_string(),
format_duration(s.total_duration_s),
s.last_activity
.as_deref()
.map(|t| t[..t.len().min(10)].to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if summaries.is_empty() {
app.log.info("no project activity recorded yet — run or start an agent inside a git repository");
}
Ok(0)
}
fn show_one(app: &App, name: &str, summaries: &[ProjectSummary]) -> Result<i32> {
let s = summaries
.iter()
.find(|s| {
s.root == name
|| s.root.ends_with(&format!("/{name}"))
|| s.root.ends_with(&format!("\\{name}"))
})
.ok_or_else(|| anyhow!("unknown project '{name}' — 'am projects' lists them"))?;
if app.json() {
print_json(s);
return Ok(0);
}
println!("project {}", s.root);
println!(" agents: {}", s.agents.join(", "));
println!(
" dominant: {}",
s.dominant.as_deref().unwrap_or("—")
);
println!(" sessions: {}", s.sessions);
println!(" launches: {}", s.launches);
println!(" duration: {}", format_duration(s.total_duration_s));
println!(
" last: {}",
s.last_activity.as_deref().unwrap_or("—")
);
Ok(0)
}
/// Keep the tail of a long path.
fn short_path(p: &str) -> String {
let s = p.trim_end_matches(['/', '\\']);
let name = s.rsplit(['/', '\\']).next().unwrap_or(s);
let mut out = format!("…/{name}");
let full = p.chars().count();
let shown = out.chars().count();
if shown >= full || full <= 30 {
return p.to_string();
}
out.truncate(30);
out
}
fn format_duration(secs: u64) -> String {
if secs == 0 {
return "—".to_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)
}
}
+130 -14
View File
@@ -2,28 +2,50 @@
use super::*;
use crate::cli::StartArgs;
use crate::events::{self, Event, EventKind};
use crate::process;
use crate::sessions;
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;
/// Resolve the start target: the explicit agent/alias/group when given,
/// otherwise the default agent of the project containing cwd (issue #30).
pub fn resolve_start_target(app: &App, cwd: &std::path::Path, agent: Option<&str>) -> Result<String> {
if let Some(a) = agent.filter(|s| !s.is_empty()) {
return Ok(a.to_string());
}
crate::projects::default_agent_for_dir(app, cwd).ok_or_else(|| {
anyhow!(
"no agent given — set default_agent in this project's profile ('am init' does it) or pass an agent name"
)
})
}
pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
start_with_cwd(app, opts, None)
}
/// Start with an explicit working directory (used by sessions resume).
pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>) -> Result<i32> {
let current = std::env::current_dir().unwrap_or_default();
let target = resolve_start_target(app, &current, opts.agent.as_deref())?;
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
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}'");
}
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
}
return Ok(0);
}
let agent = require_agent(app, &opts.agent)?;
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
let agent = require_agent(app, &target)?;
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd)
}
fn start_one(
@@ -33,12 +55,16 @@ fn start_one(
extra_env: &BTreeMap<String, String>,
notify: bool,
background: bool,
cwd: Option<&std::path::Path>,
) -> Result<i32> {
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
let bin = PathBuf::from(&exec.program);
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(&current));
if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?;
if app.dry_run() {
return Ok(0);
}
@@ -51,7 +77,30 @@ fn start_one(
agent.name
));
}
app.emit(
&Event::now(EventKind::Start)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_session(crate::repl::session_id())
.with_cwd(cwd_string())
.with_project(ctx.root.clone())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env)),
);
if is_managed {
let _ = app.state.touch(&agent.name);
}
let log = process::agent_log_path(app, &agent.name);
let _ = sessions::start_agent(
app,
&agent.name,
pid,
cwd_string(),
ctx.root.clone(),
exec.args.clone(),
events::env_keys(&exec.env),
Some(log.display().to_string()),
);
app.log.success(&format!(
"{} started (pid {pid}); log: {}",
agent.title(),
@@ -70,15 +119,31 @@ fn start_one(
));
return Ok(0);
}
let started = std::time::Instant::now();
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
let mut cmd = Command::new(&prog);
cmd.args(&full_args).envs(&exec.env);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let status = cmd
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_cwd(cwd_string())
.with_project(ctx.root.clone())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env))
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
let _ = app.state.touch(&agent.name);
Ok(code)
}
}
@@ -123,12 +188,34 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
"stale PID {pid} for '{}' (process no longer running); cleaning up",
agent.name
));
app.emit(
&Event::now(EventKind::Stop)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_reason("stale"),
);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
app.state.update_pid(&agent.name, None, None)?;
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_stop", &current);
return Ok(0);
}
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
let stopped = process::stop_pid(pid, force, timeout);
let duration = seconds_since(entry.started_at.as_deref());
let mut ev = Event::now(EventKind::Stop)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_reason(if force { "force" } else { "signal" })
.with_exit_code(if stopped { 0 } else { 1 });
if let Some(d) = duration {
ev = ev.with_duration(d);
}
app.emit(&ev);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
app.state.update_pid(&agent.name, None, None)?;
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_stop", &current);
if stopped {
app.log.success(&format!("{} stopped", agent.title()));
Ok(0)
@@ -140,7 +227,9 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -> Result<i32> {
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
let cwd = std::env::current_dir().unwrap_or_default();
let target = resolve_start_target(app, &cwd, opts.agent.as_deref())?;
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}'");
@@ -151,15 +240,15 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?;
}
return Ok(0);
}
let agent = require_agent(app, &opts.agent)?;
let agent = require_agent(app, &target)?;
stop_one(app, agent, force, timeout)?;
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None)
}
/// run: execute the agent command directly, no process management.
@@ -175,6 +264,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
));
return Ok(0);
}
let started = std::time::Instant::now();
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
@@ -183,5 +273,31 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_cwd(cwd_string())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env))
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
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())
}
/// 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()?;
let now = chrono::Utc::now();
let secs = now
.signed_duration_since(t.with_timezone(&chrono::Utc))
.num_seconds();
Some(secs.max(0) as u64)
}
+22 -6
View File
@@ -1,7 +1,7 @@
//! search: keyword search over the catalog.
use super::*;
use crate::output::{print_json, Table};
use crate::output::{boxed_table, print_json};
use serde::Serialize;
#[derive(Serialize)]
@@ -33,20 +33,36 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
print_json(&rows);
return Ok(0);
}
let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]);
let mut table_rows = Vec::new();
for a in hits {
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
table.row(vec![
table_rows.push(vec![
a.title().to_string(),
a.category.clone().unwrap_or_else(|| "—".to_string()),
if a.installable { "yes" } else { "no" }.to_string(),
desc,
]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"],
table_rows,
app.theme(),
app.color(),
)
);
if app.catalog.search(keyword, category).is_empty() {
app.log
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
let suggestions = app.catalog.suggest(keyword);
if suggestions.is_empty() {
app.log
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
} else {
app.log.info(&format!(
"no agent matches '{keyword}' — vouliez-vous dire : {} ?",
suggestions.join(", ")
));
}
}
Ok(0)
}
+55
View File
@@ -0,0 +1,55 @@
//! secret: manage secrets in the OS keyring (values never shown or logged).
use super::*;
use crate::cli::SecretCmd;
use crate::output::print_json;
use crate::secrets::{self, SecretStore};
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
match sub {
SecretCmd::Set { name, agent, value } => {
if app.dry_run() {
app.log.dry(format!("would store secret {name} for {agent} in the OS keyring"));
return Ok(0);
}
let key = secrets::key_for(agent, name);
secrets::store().set(&key, value)?;
app.log.success(&format!("secret {name} stored for {agent}"));
app.log.info("use it with --env NAME=@secret (injected at start/run)");
Ok(0)
}
SecretCmd::Unset { name, agent } => {
let key = secrets::key_for(agent, name);
if app.dry_run() {
app.log.dry(format!("would remove secret {key}"));
return Ok(0);
}
let removed = secrets::store().remove(&key)?;
if removed {
app.log.success(&format!("secret {name} removed for {agent}"));
} else {
app.log.info(&format!("secret {name} does not exist for {agent}"));
}
Ok(0)
}
SecretCmd::List => {
let all = secrets::store().list().unwrap_or_default();
if app.json() {
print_json(&all);
return Ok(0);
}
if all.is_empty() {
app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ...");
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["SECRET"]);
for k in all {
if k != "__index__" {
table.row(vec![k]);
}
}
print!("{}", table.render());
Ok(0)
}
}
}
+386
View File
@@ -0,0 +1,386 @@
//! self-uninstall: remove agent-manager and everything it created from this
//! machine — background agents are stopped first, then the data, state and
//! configuration directories are deleted, then the executable itself.
use crate::app::{self, App};
use anyhow::{bail, Result};
#[cfg(unix)]
use anyhow::Context;
use colored::Colorize;
use std::path::{Path, PathBuf};
#[cfg(windows)]
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
#[cfg(windows)]
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
#[cfg(windows)]
const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFF_FFFF;
#[cfg(windows)]
#[link(name = "Kernel32")]
extern "system" {
fn MoveFileExW(
lpExistingFileName: *const u16,
lpNewFileName: *const u16,
dwFlags: u32,
) -> i32;
fn GetFileAttributesW(lpFileName: *const u16) -> u32;
fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32;
}
pub fn run(app: &App) -> Result<i32> {
// 1. Stop every managed background agent.
let state = app.state.load()?;
for (name, entry) in &state.installed {
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log.info(&format!("stopping agent '{name}' (pid {pid})"));
crate::process::stop_pid(pid, false, 1);
}
}
}
// 2. Collect the directories agent-manager owns.
let data_root = app::data_dir();
let state_root = app::state_dir();
let mut roots: Vec<(&str, PathBuf)> = Vec::new();
if data_root.is_dir() {
roots.push(("data", data_root.clone()));
}
if state_root != data_root && state_root.is_dir() {
roots.push(("state", state_root));
}
if let Some(dir) = crate::config::user_config_dir() {
if dir.is_dir() {
roots.push(("config", dir));
}
}
let exe = std::env::current_exe().ok();
let mut renamed: Option<PathBuf> = None;
if roots.is_empty() {
app.log
.info("nothing to remove — agent-manager left no traces on this machine");
} else {
app.log.info("agent-manager will remove:");
for (label, path) in &roots {
println!(" {label}: {}", path.display());
}
if !app.confirm("Remove everything listed above?")? {
app.log.info("cancelled — nothing was removed");
return Ok(0);
}
if app.dry_run() {
app.log.dry("nothing removed (dry run)");
return Ok(0);
}
// Windows allows renaming a running executable (but not deleting
// it): do it up-front so the original binary disappears immediately,
// even if the deferred deletion has to wait for this process to exit.
renamed = exe.as_deref().and_then(rename_for_deletion);
if let Some(new) = &renamed {
if let Some(old) = &exe {
println!(
"{} {} -> {}",
"renamed:".cyan(),
old.display(),
new.display()
);
}
}
// Remove everything first; any message logged *after* deleting the
// state directory would recreate the log folder, so the removal
// itself is silent and the report below goes to plain stdout.
// The removal is tolerant: read-only attributes are cleared and files
// locked by other processes (for example the running am.exe when it
// lives inside the data directory) are scheduled for deletion at the
// next reboot instead of failing the whole uninstall.
let mut locked: Vec<PathBuf> = Vec::new();
for (_, path) in &roots {
remove_tree(path, &mut locked);
}
for (_, path) in &roots {
if !path.exists() {
println!("{} {}", "removed:".green(), path.display());
}
}
if !locked.is_empty() {
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()
);
}
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()
);
}
}
}
// 3. Delete the executable itself, then sweep the directories left
// empty behind it.
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
match &exe {
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
None => app.log
.warn("cannot locate the agent-manager executable — delete it manually"),
}
Ok(0)
}
/// Recursively remove a tree, tolerating locked or read-only entries.
/// Paths that could not be removed are collected into `locked` so the
/// caller can schedule their deletion at the next reboot.
fn remove_tree(path: &Path, locked: &mut Vec<PathBuf>) {
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(_) => return,
};
if meta.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
remove_tree(&entry.path(), locked);
}
}
clear_readonly(path);
if std::fs::remove_dir(path).is_err() {
locked.push(path.to_path_buf());
}
} else {
clear_readonly(path);
if std::fs::remove_file(path).is_err() {
locked.push(path.to_path_buf());
}
}
}
/// Clear the read-only attribute (a common cause of "Access denied" on
/// Windows, e.g. files extracted by Expand-Archive).
#[cfg(windows)]
fn clear_readonly(path: &Path) {
use std::os::windows::ffi::OsStrExt;
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let attrs = GetFileAttributesW(wide.as_ptr());
if attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY) != 0 {
SetFileAttributesW(wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
}
}
}
#[cfg(not(windows))]
fn clear_readonly(_path: &Path) {}
/// Schedule every locked path for deletion at the next reboot (Windows).
/// Returns the paths that could be scheduled.
#[cfg(windows)]
fn schedule_locked(locked: &[PathBuf]) -> Vec<PathBuf> {
use std::os::windows::ffi::OsStrExt;
let mut scheduled = Vec::new();
for path in locked {
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let ok = unsafe {
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
};
if ok {
scheduled.push(path.clone());
}
}
scheduled
}
#[cfg(not(windows))]
fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
Vec::new()
}
/// Delete the running executable. On Unix the file can be unlinked while it
/// runs; on Windows a hidden cmd re-tries the deletion right after this
/// process exits (the file is locked while we run). Empty directories left
/// behind under the removed roots are swept afterwards.
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
#[cfg(unix)]
{
std::fs::remove_file(exe)
.with_context(|| format!("cannot remove {}", exe.display()))?;
// Sweep empty directories upward, but never above a removed root.
let mut dir = exe.parent();
while let Some(d) = dir {
if !roots.iter().any(|r| d.starts_with(r)) {
break;
}
if std::fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
println!(
"{} {}",
"agent-manager removed:".green(),
exe.display()
);
return Ok(());
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
// The file to delete once this process exits: the renamed copy when
// the up-front rename worked, the original path otherwise.
let target = renamed.unwrap_or(exe);
// A temporary batch file retries the deletion for a few seconds (the
// image stays locked until we exit), sweeps the directories left
// empty, then deletes itself. A .bat avoids every cmd /C command-line
// quoting pitfall.
let bat = write_cleanup_bat(exe, target, roots);
if let Some(b) = &bat {
// raw_arg passes the command line verbatim: regular .arg()
// backslash-escapes embedded quotes, which cmd.exe does not
// understand.
use std::os::windows::process::CommandExt as _;
let mut cleanup = std::process::Command::new("cmd");
cleanup.raw_arg("/C");
cleanup.raw_arg(format!("\"{}\"", b.display()));
let _ = cleanup.spawn();
}
// Belt and braces: if something keeps the file locked, the standard
// uninstaller mechanism deletes it at the next reboot.
let wide: Vec<u16> = target
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let at_reboot = unsafe {
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
};
if bat.is_none() && !at_reboot {
bail!(
"cannot schedule deletion of {} — delete it manually",
exe.display()
);
}
if renamed.is_some() {
println!(
"{} {}",
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
exe.display()
);
} else {
println!(
"{} {}",
"agent-manager removed; the executable will be deleted momentarily:".green(),
exe.display()
);
}
return Ok(());
}
#[cfg(not(any(unix, windows)))]
{
bail!(
"automatic removal of the executable is not supported on this platform; delete {} manually",
exe.display()
);
}
}
/// Rename the running executable out of the way (Windows allows renaming a
/// running image — only deletion is refused while it executes). Returns the
/// new path on success.
#[cfg(windows)]
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
let name = exe.file_name()?.to_string_lossy();
let new = exe.with_file_name(format!("{name}.deleteme"));
std::fs::rename(exe, &new).ok()?;
Some(new)
}
#[cfg(not(windows))]
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
None
}
/// Write a temporary batch file that deletes the target executable once it
/// is unlocked, sweeps the directories left empty and then removes itself.
#[cfg(windows)]
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
let crlf = "\r\n";
let q = "\"";
let mut script = String::new();
let mut line = |s: &str| {
script.push_str(s);
script.push_str(crlf);
};
line("@echo off");
line("set n=0");
line(":retry");
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
line(&format!(
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
target.display(),
exe.display()
));
line("set /a n+=1");
line("if %n% GEQ 60 goto end");
line("ping -n 2 127.0.0.1 >nul");
line("goto retry");
line(":sweep");
if let Some(parent) = exe.parent() {
if roots.iter().any(|r| parent.starts_with(r)) {
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
}
}
for root in roots {
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
}
line(":end");
line(&format!("del {q}%~f0{q} >nul 2>&1"));
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
std::fs::write(&path, script).ok()?;
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_readonly_tree() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
let file = sub.join("readonly.txt");
std::fs::write(&file, "x").unwrap();
// Read-only files are a classic cause of "Access denied" on Windows.
let mut perms = std::fs::metadata(&file).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(true);
std::fs::set_permissions(&file, perms).unwrap();
let mut locked: Vec<PathBuf> = Vec::new();
remove_tree(dir.path(), &mut locked);
assert!(!dir.path().exists(), "tree should be fully removed");
assert!(locked.is_empty(), "unexpected locked paths: {locked:?}");
}
#[test]
fn tolerates_missing_tree() {
let mut locked: Vec<PathBuf> = Vec::new();
remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked);
assert!(locked.is_empty());
}
}
+192
View File
@@ -0,0 +1,192 @@
//! sessions: the registry of every agent and REPL session.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::sessions::{self, SessionRecord};
use serde::Serialize;
#[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>,
}
fn row_of(r: &SessionRecord) -> SessionRow {
SessionRow {
id: r.id.clone(),
kind: r.kind.clone(),
agent: r.agent.clone(),
status: r.status.clone(),
pid: r.pid,
cwd: r.cwd.clone(),
project: r.project.clone(),
started_at: r.started_at.clone(),
ended_at: r.ended_at.clone(),
duration_s: sessions::duration_s(r),
exit_code: r.exit_code,
log: r.log.clone(),
}
}
pub fn run(
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)?;
let sf = sessions::load(app)?;
let mut rows: Vec<SessionRow> = sf
.sessions
.values()
.filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a)))
.filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p)))
.filter(|r| status.map_or(true, |s| r.status == s))
.map(row_of)
.collect();
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
let mut table = Table::new(vec!["SESSION", "AGENT", "STATUS", "DURATION", "EXIT"]);
for r in &rows {
table.row(vec![
short_id(&r.id),
r.agent
.clone()
.unwrap_or_else(|| format!("({})", r.kind)),
status_style(&r.status, theme, color),
r.duration_s
.map(format_duration)
.unwrap_or_else(|| "—".to_string()),
r.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log
.info("no sessions recorded yet — 'am start <agent> --background' or run 'am'");
}
Ok(0)
}
fn show_one(app: &App, id: &str) -> Result<i32> {
sessions::reconcile(app)?;
let sf = sessions::load(app)?;
let Some(r) = sf.sessions.get(id).cloned() else {
bail!("unknown session '{id}' — 'am sessions' lists every session");
};
if app.json() {
print_json(&row_of(&r));
return Ok(0);
}
println!("session {}", r.id);
println!(" kind: {}", r.kind);
println!(" agent: {}", r.agent.as_deref().unwrap_or("—"));
println!(" status: {}", r.status);
println!(" pid: {}", r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()));
println!(" cwd: {}", r.cwd.as_deref().unwrap_or("—"));
println!(" project: {}", r.project.as_deref().unwrap_or("—"));
println!(" started: {}", r.started_at);
println!(" ended: {}", r.ended_at.as_deref().unwrap_or("—"));
if let Some(d) = sessions::duration_s(&r) {
println!(" duration: {}", format_duration(d));
}
if let Some(c) = r.exit_code {
println!(" exit code: {c}");
}
if let Some(l) = &r.log {
let p = std::path::PathBuf::from(l);
if p.exists() {
println!(" log: {l}");
let tail = crate::process::last_lines(&p, 8);
if !tail.is_empty() {
println!(" --- log excerpt ---");
println!("{tail}");
}
} else {
println!(" log: {l} (missing)");
}
}
Ok(0)
}
/// Resume a finished session: relaunch the agent in the background with
/// its recorded arguments and working directory (issue #32).
fn resume_session(app: &App, id: &str) -> Result<i32> {
sessions::reconcile(app)?;
let sf = sessions::load(app)?;
let Some(r) = sf.sessions.get(id).cloned() else {
bail!("unknown session '{id}' — 'am sessions' lists every session");
};
if r.kind != "agent" {
bail!("session '{id}' is a REPL session — start it again with 'am'");
}
if r.status == "running" {
app.log.info(&format!("session '{id}' is still running"));
return Ok(0);
}
let Some(agent_name) = r.agent.clone() else {
bail!("session '{id}' has no agent");
};
require_agent(app, &agent_name)?;
if app.dry_run() {
app.log.dry(format!(
"would resume session {id}: start {} --background with {} arg(s)",
agent_name,
r.args.len()
));
return Ok(0);
}
let cwd = r.cwd.as_deref().map(std::path::Path::new);
let opts = crate::cli::StartArgs {
agent: Some(agent_name.clone()),
background: true,
args: r.args.clone(),
..Default::default()
};
app.log.info(&format!(
"resuming session {id}: {} ({} arg(s))",
agent_name,
r.args.len()
));
crate::commands::run_cmd::start_with_cwd(app, &opts, cwd)
}
/// "20260815_143926_a1b2c3" -> "20260815_143926"
fn short_id(id: &str) -> String {
id.chars().take(15).collect()
}
fn format_duration(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)
}
}
+328
View File
@@ -0,0 +1,328 @@
//! stats: usage statistics computed from the event journal.
use super::*;
use crate::events::{self, EventKind};
use crate::output::{print_json, Table};
use anyhow::Context;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, Serialize)]
pub struct AgentStats {
pub agent: String,
pub launches: u64,
pub total_duration_s: u64,
pub avg_duration_s: u64,
pub failures: u64,
pub installs: u64,
pub updates: u64,
pub last_used: Option<String>,
}
/// Cutoff instant for a period: 7d / 30d / 90d, or None for everything.
pub fn cutoff(period: Option<&str>) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
let days = match period {
None | Some("all") => return Ok(None),
Some("7d") => 7,
Some("30d") => 30,
Some("90d") => 90,
Some(other) => bail!("invalid period '{other}' — expected 7d, 30d, 90d or all"),
};
Ok(Some(chrono::Utc::now() - chrono::Duration::days(days)))
}
/// Aggregate journal events into per-agent statistics.
pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<Vec<AgentStats>> {
let cut = cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, AgentStats> = BTreeMap::new();
let mut dur_count: BTreeMap<String, u64> = BTreeMap::new();
for ev in events {
if let Some(c) = cut {
let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) else {
continue;
};
if ts.with_timezone(&chrono::Utc) < c {
continue;
}
}
let Some(name) = ev.agent else {
continue;
};
if let Some(a) = agent {
if name != a {
continue;
}
}
let s = map.entry(name.clone()).or_default();
s.agent = name.clone();
match ev.kind {
EventKind::Start | EventKind::Run => s.launches += 1,
EventKind::Install => s.installs += 1,
EventKind::Update => s.updates += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
s.total_duration_s += d;
*dur_count.entry(name.clone()).or_default() += 1;
}
if let Some(code) = ev.exit_code {
if code != 0 {
s.failures += 1;
}
}
s.last_used = Some(ev.ts);
}
for (name, s) in map.iter_mut() {
let n = dur_count.get(name).copied().unwrap_or(0);
s.avg_duration_s = if n > 0 { s.total_duration_s / n } else { 0 };
}
Ok(map.into_values().collect())
}
/// Top 10 agents by launch count (issue #37).
pub fn top(app: &App, period: Option<&str>) -> Result<Vec<AgentStats>> {
let mut rows = aggregate(app, None, period)?;
rows.sort_by(|a, b| b.launches.cmp(&a.launches).then(b.total_duration_s.cmp(&a.total_duration_s)));
rows.truncate(10);
Ok(rows)
}
/// Markdown weekly/monthly report over the last N days (issue #37).
pub fn report_md(app: &App, days: u32) -> String {
let cut = chrono::Utc::now() - chrono::Duration::days(days as i64);
let events = events::read_events(&app.events_dir(), 0);
let mut total_launches = 0u64;
let mut failures = 0u64;
let mut changes = 0u64;
let mut duration = 0u64;
for ev in events {
let ok = chrono::DateTime::parse_from_rfc3339(&ev.ts)
.map(|t| t.with_timezone(&chrono::Utc) >= cut)
.unwrap_or(false);
if !ok {
continue;
}
match ev.kind {
EventKind::Start | EventKind::Run => total_launches += 1,
EventKind::Install | EventKind::Update | EventKind::Uninstall => changes += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
duration += d;
}
if let Some(c) = ev.exit_code {
if c != 0 {
failures += 1;
}
}
}
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
);
if !top_rows.is_empty() {
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
for r in &top_rows {
md.push_str(&format!(
"| {} | {} | {}s | {} |\n",
r.agent, r.launches, r.total_duration_s, r.failures
));
}
}
md
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
let rows = aggregate(app, agent, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
if rows.is_empty() {
app.log.info("no usage recorded yet — run or start an agent first");
return Ok(0);
}
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "AVG", "FAIL", "LAST"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
format_duration(r.total_duration_s),
format_duration(r.avg_duration_s),
if r.failures > 0 {
crate::output::status_style("error", theme, color)
} else {
r.failures.to_string()
},
r.last_used
.as_deref()
.map(|t| t[..t.len().min(10)].to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
Ok(0)
}
/// am top : the most used agents (issue #37).
pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
let rows = top(app, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
if rows.is_empty() {
app.log.info("no usage recorded yet");
return Ok(0);
}
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "FAIL"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
format_duration(r.total_duration_s),
if r.failures > 0 {
crate::output::status_style("error", theme, color)
} else {
r.failures.to_string()
},
]);
}
print!("{}", table.render());
Ok(0)
}
/// am report : write the markdown digest (issue #37).
pub fn run_report(
app: &App,
last_week: bool,
last_month: bool,
output: Option<&std::path::Path>,
) -> Result<i32> {
let days: u32 = if last_week { 7 } else { 30 };
let md = report_md(app, days);
let path = match output {
Some(p) => p.to_path_buf(),
None => {
let stamp = chrono::Local::now().format("%Y%m%d");
app.events_dir().join(format!("report-{stamp}.md"))
}
};
if app.dry_run() {
app.log.dry(format!("would write {}", path.display()));
return Ok(0);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&path, md)
.with_context(|| format!("cannot write {}", path.display()))?;
app.log.success(&format!("report written to {}", path.display()));
Ok(0)
}
/// ASCII bar: relative weight of v against max.
fn bar(v: u64, max: u64, width: usize) -> String {
if max == 0 {
return String::new();
}
let n = ((v * width as u64) / max).max(if v > 0 { 1 } else { 0 }) as usize;
format!("{}{}", "█".repeat(n), "░".repeat(width.saturating_sub(n)))
}
fn format_duration(secs: u64) -> String {
if secs == 0 {
return "—".to_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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with_events(events_jsonl: &str) -> crate::app::App {
// The TempDir guard would delete the directory when this helper
// returns; leak it on purpose so the App keeps a live events dir.
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,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\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;
std::fs::write(dir.join("events-test.jsonl"), events_jsonl).unwrap();
// Rename into the right month file so read_events picks it up.
let month = chrono::Utc::now().format("%Y%m");
std::fs::rename(
dir.join("events-test.jsonl"),
dir.join(format!("events-{month}.jsonl")),
)
.unwrap();
app
}
#[test]
fn aggregates_counts_durations_and_failures() {
let lines = [
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
];
let app = app_with_events(&lines.join("\n"));
let rows = aggregate(&app, None, None).unwrap();
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.launches, 2);
assert_eq!(a.total_duration_s, 40);
assert_eq!(a.avg_duration_s, 20);
assert_eq!(a.failures, 0);
assert_eq!(a.installs, 1);
let b = rows.iter().find(|r| r.agent == "b").unwrap();
assert_eq!(b.failures, 1);
assert_eq!(b.launches, 1);
}
#[test]
fn period_cuts_old_events() {
let app = app_with_events(
r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#,
);
assert!(aggregate(&app, None, Some("30d")).unwrap().is_empty());
assert_eq!(aggregate(&app, None, None).unwrap().len(), 1);
assert!(cutoff(Some("bad")).is_err());
}
}
+41 -18
View File
@@ -1,7 +1,7 @@
//! status: detailed state of one agent, or of every installed agent.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::output::{boxed_table, kv_table, print_json, status_style};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
@@ -39,28 +39,42 @@ fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result<i32> {
return Ok(0);
}
let color = app.color();
let theme = app.theme();
println!("{} ({})", agent.title(), agent.name);
let status_line = status_style(&detail.status, color);
println!(" status: {status_line}");
println!(
" version: {}",
detail.version.as_deref().unwrap_or("unknown")
);
println!(" method: {}", detail.method.as_deref().unwrap_or("—"));
println!(" path: {}", detail.path.as_deref().unwrap_or("—"));
println!(" run: {}", detail.run.as_deref().unwrap_or("—"));
let status_line = status_style(&detail.status, theme, color);
let mut rows: Vec<(String, String)> = vec![
("agent".to_string(), agent.name.clone()),
("status".to_string(), status_line),
(
"version".to_string(),
detail.version.clone().unwrap_or_else(|| "unknown".to_string()),
),
(
"method".to_string(),
detail.method.clone().unwrap_or_else(|| "—".to_string()),
),
(
"path".to_string(),
detail.path.clone().unwrap_or_else(|| "—".to_string()),
),
(
"run".to_string(),
detail.run.clone().unwrap_or_else(|| "—".to_string()),
),
];
if let Some(pid) = detail.pid {
println!(" pid: {pid}");
rows.push(("pid".to_string(), pid.to_string()));
}
if let Some(t) = &detail.started_at {
println!(" started: {t}");
rows.push(("started".to_string(), t.clone()));
}
if let Some(t) = &detail.installed_at {
println!(" installed: {t}");
rows.push(("installed".to_string(), t.clone()));
}
if let Some(l) = &detail.log {
println!(" log: {l}");
rows.push(("log".to_string(), l.clone()));
}
print!("{}", kv_table(&rows, theme, color));
Ok(0)
}
@@ -90,17 +104,26 @@ fn status_all(app: &App, probe: &ExternalInfo) -> Result<i32> {
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]);
let theme = app.theme();
let mut table_rows = Vec::new();
for r in &rows {
table.row(vec![
table_rows.push(vec![
r.display_name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
status_style(&r.status, theme, color),
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["NAME", "VERSION", "STATUS", "PID", "PATH"],
table_rows,
theme,
color,
)
);
if rows.is_empty() {
app.log.info("no installed or running agents");
}
+179
View File
@@ -0,0 +1,179 @@
//! timeline: one chronological view over the event journal and the REPL
//! command history.
use super::*;
use crate::events::{self, Event};
use crate::history::{self, HistoryEntry};
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct TimelineRow {
pub ts: String,
pub source: String,
pub kind: String,
pub agent: Option<String>,
pub summary: String,
}
/// Merge events and history entries into one chronological list, most
/// recent first, filtered by agent/project/date and capped by limit.
pub fn collect(
app: &App,
agent: Option<&str>,
project: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<Vec<TimelineRow>> {
let cut = match since {
Some(s) => Some(crate::commands::log_cmd::parse_since(s)?),
None => None,
};
let mut rows: Vec<TimelineRow> = Vec::new();
for ev in events::read_events(&app.events_dir(), 0) {
if agent.map_or(true, |a| ev.agent.as_deref() == Some(a))
&& project.map_or(true, |p| ev.project.as_deref() == Some(p))
{
if let Some(c) = cut {
if chrono::DateTime::parse_from_rfc3339(&ev.ts)
.map(|t| t < c)
.unwrap_or(true)
{
continue;
}
}
rows.push(event_row(&ev));
}
}
// REPL history entries carry no agent/project: include them only
// when no such filter is set.
if agent.is_none() && project.is_none() {
for (_, e) in crate::commands::history_cmd::filter(app, None, None, None, false, None, 0) {
let ts_ok = match cut {
Some(c) => chrono::DateTime::parse_from_rfc3339(&e.ts)
.map(|t| t >= c)
.unwrap_or(false),
None => true,
};
if !ts_ok {
continue;
}
rows.push(TimelineRow {
ts: e.ts.clone(),
source: "repl".to_string(),
kind: e.kind.clone(),
agent: e.agent.clone(),
summary: e.cmd.clone(),
});
}
}
rows.sort_by(|a, b| b.ts.cmp(&a.ts));
rows.truncate(if limit > 0 { limit } else { rows.len() });
Ok(rows)
}
fn event_row(ev: &Event) -> TimelineRow {
let mut parts = Vec::new();
if let Some(p) = ev.pid {
parts.push(format!("pid {p}"));
}
if let Some(d) = ev.duration_s {
parts.push(format!("{d}s"));
}
if let Some(c) = ev.exit_code {
parts.push(format!("exit {c}"));
}
TimelineRow {
ts: ev.ts.clone(),
source: "journal".to_string(),
kind: ev.kind.as_str().to_string(),
agent: ev.agent.clone(),
summary: if parts.is_empty() {
ev.project.clone().unwrap_or_default()
} else {
parts.join(", ")
},
}
}
pub fn run(
app: &App,
agent: Option<&str>,
project: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<i32> {
let rows = collect(app, agent, project, since, limit)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
for r in &rows {
let ts = r.ts.get(0..16).unwrap_or(&r.ts);
let actor = r.agent.as_deref().unwrap_or("repl");
println!("{ts} {:<9} {:<16} {}", r.kind, actor, r.summary);
}
if rows.is_empty() {
app.log.info("no activity recorded yet");
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with(lines: &[&str]) -> 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,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut 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;
let month = chrono::Utc::now().format("%Y%m");
let path = dir.join(format!("events-{month}.jsonl"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, lines.join("\n")).unwrap();
app
}
#[test]
fn merges_events_and_history_chronologically() {
let app = app_with(&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":5}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"start","agent":"a","pid":42}"#,
]);
history::record(&app, "s1", &history::HistoryEntry::now("list --all")).unwrap();
let rows = collect(&app, None, None, None, 0).unwrap();
assert!(rows.len() >= 3, "events + history: {rows:?}");
// Ordre antichronologique strict.
let ts: Vec<&str> = rows.iter().map(|r| r.ts.as_str()).collect();
let mut sorted = ts.clone();
sorted.sort_by(|a, b| b.cmp(a));
assert_eq!(ts, sorted);
// Filtres.
assert_eq!(collect(&app, Some("a"), None, None, 0).unwrap().len(), 2);
assert_eq!(collect(&app, Some("zzz"), None, None, 0).unwrap().len(), 0);
assert_eq!(collect(&app, None, None, None, 2).unwrap().len(), 2);
}
}
+213 -5
View File
@@ -1,19 +1,20 @@
//! uninstall: stop the agent if needed, remove its files, bins and state
//! entry; --purge also removes logs and the user-config definition.
//! External agents (installed outside agent-manager) are removed through
//! their package manager when the catalog knows it, or by deleting the
//! executables found on PATH otherwise.
use super::*;
use crate::events::{Event, EventKind};
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
let agent = require_agent(app, agent_name)?;
let entry = app.state.get(&agent.name).ok().flatten();
let Some(entry) = entry else {
app.log.info(&format!(
"agent '{}' is not managed by agent-manager — nothing to uninstall",
agent.name
));
return Ok(0);
return uninstall_external(app, agent);
};
// Stop it first when running.
@@ -57,6 +58,7 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
remove_from_user_config(app, &agent.name)?;
}
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
app.log.success(&format!(
"uninstalled {} (files removed{})",
agent.title(),
@@ -65,6 +67,135 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
Ok(0)
}
/// Build the package-manager uninstall command for an external agent whose
/// install method is a known package manager and whose package is recorded.
fn package_plan(agent: &AgentDef) -> Option<Vec<String>> {
use crate::config::InstallType;
let spec = agent.install.as_ref()?;
for m in spec.all_methods() {
let Some(pkg) = m.package.as_deref() else {
continue;
};
let plan = match m.kind {
InstallType::Npm => {
vec!["npm", "uninstall", "--global", pkg]
}
InstallType::Pip => {
if which::which("pip").is_ok() {
vec!["pip", "uninstall", "-y", pkg]
} else if which::which("python3").is_ok() {
vec!["python3", "-m", "pip", "uninstall", "-y", pkg]
} else if which::which("python").is_ok() {
vec!["python", "-m", "pip", "uninstall", "-y", pkg]
} else {
continue;
}
}
InstallType::Uv => vec!["uv", "tool", "uninstall", pkg],
InstallType::Cargo => vec!["cargo", "uninstall", pkg],
InstallType::Bun => vec!["bun", "remove", "--global", pkg],
_ => continue,
};
return Some(plan.into_iter().map(|s| s.to_string()).collect());
}
None
}
/// Best-effort removal of an agent installed outside agent-manager: first the
/// package-manager command when the catalog knows one, then the executable
/// files left on PATH (and their directory when it is dedicated to the
/// agent, e.g. AppData\Local\atomic-agent).
fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
let first_token = agent.first_token();
// 1. Package-manager plan (npm, pip, uv, cargo, bun).
if let Some(tokens) = package_plan(agent) {
if which::which(&tokens[0]).is_ok() {
app.log.info(&format!(
"agent '{}' is external (installed outside agent-manager) — uninstall with: {}",
agent.name,
tokens.join(" ")
));
if !app.confirm("Run this command?")? {
app.log.info("cancelled — nothing was uninstalled");
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!("would run: {}", tokens.join(" ")));
} else {
// resolve_program handles Windows .cmd/.bat shims (npm,
// pip, ...) which std::process::Command cannot launch.
let (prog, prefix) = crate::runner::resolve_program(&tokens[0]);
let mut args = prefix;
args.extend(tokens[1..].iter().cloned());
let status = ProcessCommand::new(&prog)
.args(&args)
.status()
.with_context(|| format!("failed to run {}", tokens.join(" ")))?;
if !status.success() {
app.log.warn("the command failed — removing the files it left behind");
}
}
}
}
// 2. Whatever executable is still on PATH, delete it directly (and its
// directory when it belongs to the agent).
let still = first_token.as_deref().and_then(|t| which::which(t).ok());
if let Some(path) = still {
let parent = path.parent().map(|p| p.to_path_buf());
let dedicated = parent
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name))
.unwrap_or(false);
let target: PathBuf = if dedicated {
parent.clone().unwrap_or_else(|| path.clone())
} else {
path.clone()
};
app.log.info(&format!(
"remove leftover {} {}",
if dedicated { "directory" } else { "file" },
target.display()
));
if !app.confirm("Delete it?")? {
app.log.info(&format!("'{}' left in place", target.display()));
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!("would remove {}", target.display()));
} else if dedicated {
std::fs::remove_dir_all(&target)
.with_context(|| format!("cannot remove {}", target.display()))?;
} else {
std::fs::remove_file(&target)
.with_context(|| format!("cannot remove {}", target.display()))?;
}
}
// 3. Final verification.
let gone = first_token
.as_deref()
.and_then(|t| which::which(t).ok())
.is_none();
if gone {
app.emit(
&Event::now(EventKind::Uninstall)
.with_agent(agent.name.clone())
.with_reason("external"),
);
app.log
.success(&format!("uninstalled {} (external)", agent.title()));
} else {
app.log.warn(&format!(
"{} is still present on PATH — remove it manually",
agent.title()
));
}
Ok(0)
}
/// Remove the agent entry from the user config file (--purge).
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
let Some(path) = &app.config_origin else {
@@ -111,3 +242,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_from_yaml(yaml: &str) -> AgentDef {
#[derive(serde::Deserialize)]
struct Wrap {
agents: Vec<AgentDef>,
}
let w: Wrap = serde_yaml::from_str(yaml).unwrap();
w.agents.into_iter().next().unwrap()
}
#[test]
fn package_plan_npm() {
let a = agent_from_yaml(
r#"
agents:
- name: x
install:
type: npm
package: "@scope/pkg"
run: x
"#,
);
let plan = package_plan(&a).unwrap();
assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]);
}
#[test]
fn package_plan_cargo() {
let a = agent_from_yaml(
r#"
agents:
- name: x
install:
type: cargo
package: my-crate
run: x
"#,
);
let plan = package_plan(&a).unwrap();
assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]);
}
#[test]
fn package_plan_binary_is_none() {
let a = agent_from_yaml(
r#"
agents:
- name: y
install:
type: binary
repo: owner/repo
binary: y
run: y
"#,
);
assert!(package_plan(&a).is_none());
}
#[test]
fn package_plan_unknown_method_is_none() {
let a = agent_from_yaml(
r#"
agents:
- name: z
install:
type: git
repo: https://example.com/z.git
run: z
"#,
);
assert!(package_plan(&a).is_none());
}
}
+11
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists.
use super::*;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::{Runner, SystemRunner};
use anyhow::{bail, Result};
@@ -96,6 +97,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
new_entry.updated_at = Some(installers::now_rfc3339());
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&new_entry)?;
app.emit(
&Event::now(EventKind::Update)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
crate::hooks::run_hooks(
app,
"on_update",
&std::env::current_dir().unwrap_or_default(),
);
app.log.success(&format!(
"updated {} to v{version}",
agent.title()
+15
View File
@@ -0,0 +1,15 @@
//! version: version and build information, Nushell-style.
use super::*;
use crate::help::{version_info, version_table};
use crate::output::print_json;
pub fn run(app: &App) -> Result<i32> {
let info = version_info();
if app.json() {
print_json(&info);
return Ok(0);
}
print!("{}", version_table(&info, app.theme(), app.color()));
Ok(0)
}
+107
View File
@@ -0,0 +1,107 @@
//! watch: supervise one agent and optionally restart it on crash.
use super::*;
use crate::cli::StartArgs;
use crate::process;
use std::time::Duration;
/// One supervision check. Returns whether the agent is running afterwards.
/// Restarts are background starts (event + session recorded as usual).
pub fn check_once(app: &App, agent_name: &str, restart: bool, notify: bool) -> bool {
let entry = app.state.get(agent_name).ok().flatten();
let running = entry
.as_ref()
.and_then(|e| e.pid)
.map(process::is_running)
.unwrap_or(false);
if running {
return true;
}
if !restart {
app.log.warn(&format!("'{agent_name}' is not running"));
return false;
}
let agent = match app.catalog.resolve(agent_name) {
Some(a) => a,
None => {
app.log.error(&format!("unknown agent '{agent_name}'"));
return false;
}
};
app.log.warn(&format!("'{agent_name}' is not running — restarting"));
let opts = StartArgs {
agent: Some(agent_name.to_string()),
background: true,
..Default::default()
};
match run_cmd::start(app, &opts) {
Ok(_) => {
if notify {
process::desktop_notify("agent-manager", &format!("{} restarted", agent.title()));
}
true
}
Err(e) => {
app.log.error(&format!("restart failed: {e:#}"));
false
}
}
}
pub fn run(app: &App, agent: &str, restart: bool, notify: bool, interval: u64) -> Result<i32> {
require_agent(app, agent)?;
let interval = interval.max(1);
app.log.info(&format!("watching '{agent}' every {interval}s (Ctrl-C to stop)"));
let mut backoff = 1u64;
loop {
let ok = check_once(app, agent, restart, notify);
if ok {
backoff = 1;
std::thread::sleep(Duration::from_secs(interval));
} else if restart {
app.log.warn(&format!("retrying in {backoff}s"));
std::thread::sleep(Duration::from_secs(backoff));
backoff = (backoff * 2).min(60);
} else {
std::thread::sleep(Duration::from_secs(interval));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn check_once_reports_dead_agent_without_restart() {
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,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut 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;
// Agent absent de l état : pas running, pas de restart -> false.
assert!(!check_once(&app, "claude-code", false, false));
// Agent inconnu avec restart : erreur loguée, false.
assert!(!check_once(&app, "inconnu", true, false));
}
}
+37 -1
View File
@@ -35,6 +35,9 @@ pub struct Config {
pub include: Vec<String>,
#[serde(default)]
pub agents: Vec<AgentDef>,
/// Per-project profiles: root, default agent, env and hooks.
#[serde(default)]
pub projects: BTreeMap<String, ProjectProfile>,
}
fn default_version() -> String {
@@ -50,7 +53,8 @@ pub struct Settings {
/// Directory for logs.
#[serde(default)]
pub log_dir: Option<String>,
/// Shell used to execute install scripts (defaults to sh on Unix).
/// Shell used to execute install scripts and to run system commands
/// in the interactive prompt (defaults to the user's default shell).
#[serde(default)]
pub default_shell: Option<String>,
/// Try to install missing dependencies automatically.
@@ -65,6 +69,31 @@ pub struct Settings {
/// GitHub repository (owner/repo) used by the self-update command.
#[serde(default)]
pub self_update_repo: Option<String>,
/// Color theme for the output (see 'am help theme' for the list; the
/// --theme flag overrides it).
#[serde(default)]
pub theme: Option<String>,
/// Lifecycle hooks: on_install / on_start / on_stop / on_update command
/// lines (global; project profiles can add their own).
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
}
/// A project profile: everything am needs to know about one project.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProjectProfile {
/// Root directory of the project (accepts ~ and env variables).
pub root: String,
/// Agent launched by 'am start' without arguments inside the project.
#[serde(default)]
pub default_agent: Option<String>,
/// Environment variables injected when launching the default agent.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Hooks: on_start / on_stop / on_install / on_update command lines.
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
}
impl Settings {
@@ -472,6 +501,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.self_update_repo.is_some() {
s.self_update_repo = o.self_update_repo;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
for (k, v) in overlay.aliases {
base.aliases.insert(k, v);
}
@@ -485,6 +517,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
base.agents.push(agent);
}
}
for (k, v) in overlay.projects {
base.projects.insert(k, v);
}
}
/// Validate a merged config. Returns a list of human-readable problems.
@@ -623,6 +658,7 @@ mod tests {
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len());
assert!(cfg.agents.iter().any(|a| a.name == "claude-code"));
assert!(cfg.agents.iter().any(|a| a.name == "deepseek-harness"));
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
}
+153
View File
@@ -0,0 +1,153 @@
//! Project context detection: git root + branch and the technology
//! stack of a directory (marker files). Cached per process so repeated
//! calls (event emission, sessions) never re-run subprocesses.
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct ProjectContext {
/// Root of the enclosing git repository (or None).
pub root: Option<String>,
/// Current branch (or None).
pub branch: Option<String>,
/// Detected technology stack, e.g. ["rust", "node"].
pub stack: Vec<String>,
}
/// Marker file -> stack label. Checked walking up from the directory to the
/// filesystem root; the first directory containing a marker wins.
const MARKERS: &[(&str, &str)] = &[
("Cargo.toml", "rust"),
("package.json", "node"),
("pyproject.toml", "python"),
("requirements.txt", "python"),
("setup.py", "python"),
("go.mod", "go"),
("bun.lockb", "bun"),
("bun.lock", "bun"),
("Gemfile", "ruby"),
];
static CACHE: OnceLock<Mutex<HashMap<PathBuf, ProjectContext>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<PathBuf, ProjectContext>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Detect the project context of a directory (cached per process).
pub fn detect(cwd: &Path) -> ProjectContext {
if let Some(hit) = cache().lock().unwrap().get(cwd).cloned() {
return hit;
}
let ctx = detect_uncached(cwd);
cache().lock().unwrap().insert(cwd.to_path_buf(), ctx.clone());
ctx
}
fn detect_uncached(cwd: &Path) -> ProjectContext {
let (root, branch) = git_info(cwd);
let stack = detect_stack(cwd);
ProjectContext {
root,
branch,
stack,
}
}
/// Git root and branch via git subprocesses (best effort, silent).
fn git_info(cwd: &Path) -> (Option<String>, Option<String>) {
let root = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let branch = std::process::Command::new("git")
.args(["branch", "--show-current"])
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|b| !b.is_empty());
(root, branch)
}
/// Walk up from cwd collecting stack labels from marker files.
pub fn detect_stack(cwd: &Path) -> Vec<String> {
let mut dir = Some(cwd);
let mut out: Vec<String> = Vec::new();
while let Some(d) = dir {
for (marker, label) in MARKERS {
if d.join(marker).is_file() && !out.iter().any(|s| s == label) {
out.push(label.to_string());
}
}
dir = d.parent();
}
out.sort();
out
}
/// Agents recommended for a detected stack (catalog slugs).
pub fn recommended_agents(stack: &[String]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
if stack.contains(&"rust".to_string()) {
out.push("smelt");
}
if stack.contains(&"python".to_string()) {
out.push("aider");
}
if stack.contains(&"node".to_string()) {
out.push("claude-code");
}
if out.is_empty() {
out.push("claude-code");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_stack_from_markers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let stack = detect_stack(dir.path());
assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]);
assert_eq!(recommended_agents(&stack), vec!["smelt", "claude-code"]);
}
#[test]
fn walks_up_to_parent_directories() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "").unwrap();
let sub = dir.path().join("src/deep");
std::fs::create_dir_all(&sub).unwrap();
let stack = detect_stack(&sub);
assert_eq!(stack, vec!["go".to_string()]);
}
#[test]
fn empty_dir_recommends_default() {
let dir = tempfile::tempdir().unwrap();
let stack = detect_stack(dir.path());
assert!(stack.is_empty());
assert_eq!(recommended_agents(&stack), vec!["claude-code"]);
}
#[test]
fn cache_returns_same_context() {
let dir = tempfile::tempdir().unwrap();
let a = detect(dir.path());
let b = detect(dir.path());
assert_eq!(a, b);
}
}
+7
View File
@@ -248,6 +248,13 @@ pub fn fix(app: &App) -> Result<Vec<String>> {
repaired.push(format!("created {}", app.paths.install_dir.display()));
repaired.push(format!("created {}", app.paths.log_dir.display()));
// Project cache: always refreshed (it derives from the journal).
let cache = crate::projects::cache_file(app);
if cache.exists() {
std::fs::remove_file(&cache).ok();
repaired.push(format!("project cache {} removed (rebuilt on next 'am projects')", cache.display()));
}
// Corrupt state file: back it up and start fresh.
if app.state.path().exists() && app.state.load().is_err() {
let backup = app
+255 -38
View File
@@ -139,23 +139,133 @@ pub struct GhRelease {
pub assets: Vec<GhAsset>,
}
/// Authorization header for api.github.com when GITHUB_TOKEN or GH_TOKEN
/// is set (raises the rate limit from 60 to 5000 requests/hour).
fn github_auth_header() -> Option<String> {
let token = std::env::var("GITHUB_TOKEN")
.or_else(|_| std::env::var("GH_TOKEN"))
.ok()
.filter(|t| !t.trim().is_empty())?;
Some(format!("Bearer {}", token.trim()))
}
pub fn latest_release(repo: &str) -> Result<GhRelease> {
match latest_release_api(repo) {
Ok(release) => Ok(release),
Err(e) if format!("{e:#}").contains("rate limit") => {
// The API is rate limited (60 unauthenticated requests/hour):
// fall back to the GitHub web pages, which are not limited.
latest_release_web(repo)
}
Err(e) => Err(e),
}
}
fn latest_release_api(repo: &str) -> Result<GhRelease> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let resp = ureq::get(&url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow!("no GitHub releases found for repository '{repo}'")
}
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
})?;
let mut req = ureq::get(&url).set("User-Agent", USER_AGENT);
if let Some(auth) = github_auth_header() {
req = req.set("Authorization", &auth);
}
let resp = req.call().map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow!("no GitHub releases found for repository '{repo}'")
}
ureq::Error::Status(403, _) => anyhow!(
"GitHub API request failed for '{repo}': rate limit exceeded — set the GITHUB_TOKEN environment variable to raise the limit, or retry in a few minutes"
),
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
})?;
let text = resp
.into_string()
.context("reading GitHub API response")?;
serde_json::from_str::<GhRelease>(&text).context("parsing GitHub API response")
}
/// Discover the latest release without the API: the /releases/latest page
/// redirects to the tagged page (tag from the final URL), then the
/// expanded_assets endpoint lists every asset. These web pages are not
/// subject to the API rate limit.
fn latest_release_web(repo: &str) -> Result<GhRelease> {
let latest = format!("https://github.com/{repo}/releases/latest");
let resp = ureq::get(&latest)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| anyhow!("cannot reach GitHub releases page for '{repo}': {e}"))?;
let final_url = resp.get_url().to_string();
let tag = final_url
.rsplit('/')
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty() && final_url.contains("/releases/tag/"))
.ok_or_else(|| {
anyhow!("no GitHub releases found for repository '{repo}' (no tagged release page)")
})?;
let assets_url = format!("https://github.com/{repo}/releases/expanded_assets/{tag}");
let resp = ureq::get(&assets_url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| anyhow!("cannot list GitHub release assets for '{repo}': {e}"))?;
let html = resp
.into_string()
.context("reading GitHub expanded_assets response")?;
let mut assets = parse_expanded_assets(&html, repo, &tag);
if assets.is_empty() {
anyhow::bail!(
"GitHub release '{repo}@{tag}' has no downloadable assets (web fallback)"
);
}
assets.sort_by(|a, b| a.name.cmp(&b.name));
assets.dedup_by(|a, b| a.name == b.name);
Ok(GhRelease {
tag_name: tag,
assets,
})
}
/// Extract the asset links from a GitHub expanded_assets HTML fragment.
fn parse_expanded_assets(html: &str, repo: &str, tag: &str) -> Vec<GhAsset> {
let marker = format!("/{repo}/releases/download/");
let mut assets = Vec::new();
let mut rest = html;
while let Some(pos) = rest.find(&marker) {
let start = pos + marker.len();
let end = rest[start..]
.find(['"', '\'', '<'])
.map(|i| start + i)
.unwrap_or(rest.len());
let raw = &rest[start..end];
// The href reads "download/<tag>/<asset-name>": keep the name only.
let raw = raw.strip_prefix(&format!("{tag}/")).unwrap_or(raw);
// GitHub sometimes hides zero-width characters inside hrefs.
let name = raw.replace('\u{200b}', "");
let name = decode_html_entities(&name);
if !name.is_empty() {
assets.push(GhAsset {
name: name.clone(),
browser_download_url: format!(
"https://github.com/{repo}/releases/download/{tag}/{name}"
),
size: 0,
});
}
rest = &rest[end..];
}
assets
}
fn decode_html_entities(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&#34;", "\"")
.replace("&#32;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&nbsp;", " ")
}
pub struct PickedAsset {
pub name: String,
pub url: String,
@@ -281,6 +391,14 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
if lower.contains("static") || lower.contains("portable") {
s += 1;
}
// Prefer plain archives (extracted to a single executable) over
// installer/setup programs, which cannot be used directly.
if lower.ends_with(".zip") || lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
s += 2;
}
if lower.contains("setup") || lower.contains("installer") || lower.contains("-install") {
s = s.saturating_sub(3);
}
if lower.contains("armv7")
|| lower.contains("armhf")
|| lower.contains("armv6")
@@ -296,41 +414,102 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
// Archives and executables
// ---------------------------------------------------------------------------
/// Extract zip / tar.gz / tgz / tar / gz archives. Returns false when the
/// file is not a recognized archive (caller treats it as a plain binary).
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
/// What kind of archive a file is, sniffed from its content.
enum ArchiveKind {
Zip,
TarGz,
Tar,
Gz,
Plain,
}
/// Detect the archive type from the file's magic bytes, falling back to the
/// file name. Downloads are stored under a generic name ("download"), so
/// content sniffing is the reliable way to recognize a zip/tar.gz asset.
fn sniff_archive(path: &Path) -> Result<ArchiveKind> {
let mut head = [0u8; 512];
let n = std::fs::File::open(path)
.and_then(|mut f| std::io::Read::read(&mut f, &mut head))
.unwrap_or(0);
if n >= 4 && &head[0..4] == b"PK\x03\x04" {
return Ok(ArchiveKind::Zip);
}
if n >= 2 && head[0] == 0x1f && head[1] == 0x8b {
// gzip: peek at the decompressed stream to see whether it is a tar.
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut inner = [0u8; 512];
let m = std::io::Read::read(&mut dec, &mut inner).unwrap_or(0);
if m >= 262 && &inner[257..262] == b"ustar" {
return Ok(ArchiveKind::TarGz);
}
return Ok(ArchiveKind::Gz);
}
if n >= 262 && &head[257..262] == b"ustar" {
return Ok(ArchiveKind::Tar);
}
// Content is not an archive: keep the name-based detection for files
// that still carry a real archive extension.
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
std::fs::create_dir_all(dest)?;
if name.ends_with(".zip") {
extract_zip(path, dest)?;
Ok(true)
} else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
let f = std::fs::File::open(path)?;
let dec = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(dec);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".tar") {
let f = std::fs::File::open(path)?;
let mut ar = tar::Archive::new(f);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".gz") {
let inner = name.trim_end_matches(".gz").to_string();
let out = dest.join(inner);
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut o = std::fs::File::create(&out)?;
std::io::copy(&mut dec, &mut o)?;
Ok(true)
} else {
Ok(false)
return Ok(ArchiveKind::Zip);
}
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
return Ok(ArchiveKind::TarGz);
}
if name.ends_with(".tar") {
return Ok(ArchiveKind::Tar);
}
if name.ends_with(".gz") {
return Ok(ArchiveKind::Gz);
}
Ok(ArchiveKind::Plain)
}
/// Extract zip / tar.gz / tgz / tar / gz archives, detected by content.
/// Returns false when the file is not a recognized archive (caller treats
/// it as a plain binary).
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
std::fs::create_dir_all(dest)?;
match sniff_archive(path)? {
ArchiveKind::Zip => {
extract_zip(path, dest)?;
Ok(true)
}
ArchiveKind::TarGz => {
let f = std::fs::File::open(path)?;
let dec = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(dec);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
}
ArchiveKind::Tar => {
let f = std::fs::File::open(path)?;
let mut ar = tar::Archive::new(f);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
}
ArchiveKind::Gz => {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
let inner = name.trim_end_matches(".gz").to_string();
let out = dest.join(inner);
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut o = std::fs::File::create(&out)?;
std::io::copy(&mut dec, &mut o)?;
Ok(true)
}
ArchiveKind::Plain => Ok(false),
}
}
@@ -436,6 +615,24 @@ mod tests {
assert!(validate_url("javascript:alert(1)").is_err());
}
#[test]
fn parses_expanded_assets_fragment() {
let html = r#"<div><a href="/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe">x</a>
<a href="/owner/repo/releases/download/v1.2.3/tool-windows-x86_64&#32;copy.zip">y</a>
<span>ignore me</span>
<a href="/owner/repo/releases/download/v1.2.3/SHA256SUMS">z</a></div>"#;
let assets = parse_expanded_assets(html, "owner/repo", "v1.2.3");
let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
assert!(names.contains(&"tool-windows-x86_64.exe"), "{names:?}");
assert!(names.contains(&"tool-windows-x86_64 copy.zip"), "{names:?}");
assert!(names.contains(&"SHA256SUMS"), "{names:?}");
assert_eq!(assets.len(), 3);
assert_eq!(
assets[0].browser_download_url,
"https://github.com/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe"
);
}
#[test]
fn sha256_verification() {
let dir = tempfile::tempdir().unwrap();
@@ -447,6 +644,26 @@ mod tests {
.unwrap_err();
}
#[test]
fn extracts_zip_detected_by_magic_bytes() {
// Downloads land under a generic name with no extension: only the
// PK magic bytes reveal that it is a zip.
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("download");
{
let f = std::fs::File::create(&payload).unwrap();
let mut zip = zip::ZipWriter::new(f);
let opts = zip::write::FileOptions::default();
zip.start_file("zeroclaw.exe", opts).unwrap();
std::io::Write::write_all(&mut zip, b"MZfake").unwrap();
zip.finish().unwrap();
}
let out = dir.path().join("out");
assert!(extract_archive(&payload, &out).unwrap());
let found = find_executable(&out, Some("zeroclaw")).unwrap();
assert!(found.ends_with("zeroclaw.exe"), "{found:?}");
}
#[test]
fn extracts_and_finds() {
let dir = tempfile::tempdir().unwrap();
+432
View File
@@ -0,0 +1,432 @@
//! Event journal: every action taken by agent-manager is appended as one
//! JSON line per event in `events-YYYYMM.jsonl` files inside the state
//! directory. The journal is the source of truth for stats, sessions and
//! project aggregation (ROADMAP.md, axe 1).
//!
//! Design rules:
//! * append-only, one compact JSON object per line;
//! * monthly rotation (file name derived from the event timestamp);
//! * corrupt lines are skipped on read (never fatal);
//! * no long locks: plain append with a short retry loop for Windows
//! file locks.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
/// Kind of event, serialized in snake_case.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Start,
Stop,
Run,
Restart,
Install,
Update,
Uninstall,
Doctor,
Config,
Repl,
Shell,
}
impl EventKind {
pub fn as_str(&self) -> &'static str {
match self {
EventKind::Start => "start",
EventKind::Stop => "stop",
EventKind::Run => "run",
EventKind::Restart => "restart",
EventKind::Install => "install",
EventKind::Update => "update",
EventKind::Uninstall => "uninstall",
EventKind::Doctor => "doctor",
EventKind::Config => "config",
EventKind::Repl => "repl",
EventKind::Shell => "shell",
}
}
}
/// One journal entry. Optional fields are omitted from the JSON line.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// RFC 3339 timestamp (UTC), used to pick the monthly file.
pub ts: String,
pub kind: EventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
/// Variable names only — values (secrets) never enter the journal.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_keys: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_s: Option<u64>,
}
impl Event {
/// Event stamped with the current UTC time.
pub fn now(kind: EventKind) -> Self {
Self {
ts: crate::installers::now_rfc3339(),
kind,
agent: None,
pid: None,
cwd: None,
project: None,
session: None,
reason: None,
args: Vec::new(),
env_keys: Vec::new(),
exit_code: None,
duration_s: None,
}
}
pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
self.agent = Some(agent.into());
self
}
pub fn with_pid(mut self, pid: u32) -> Self {
self.pid = Some(pid);
self
}
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
pub fn with_project(mut self, project: Option<String>) -> Self {
self.project = project;
self
}
pub fn with_session(mut self, session: impl Into<String>) -> Self {
self.session = Some(session.into());
self
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
pub fn with_env_keys(mut self, env_keys: Vec<String>) -> Self {
self.env_keys = env_keys;
self
}
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = Some(code);
self
}
pub fn with_duration(mut self, secs: u64) -> Self {
self.duration_s = Some(secs);
self
}
}
/// Sorted list of the variable names passed to a process. Values are never
/// logged — only the keys — so secrets stay out of the journal.
pub fn env_keys(env: &BTreeMap<String, String>) -> Vec<String> {
let mut keys: Vec<String> = env.keys().cloned().collect();
keys.sort();
keys
}
/// Month of an RFC 3339 timestamp: "2026-08-16T13:49:11Z" -> "202608".
pub fn month_of(ts: &str) -> String {
ts.chars()
.take(7)
.filter(|c| c.is_ascii_digit())
.collect()
}
/// Journal file name for a month: "202608" -> "events-202608.jsonl".
pub fn file_name_for_month(month: &str) -> String {
format!("events-{month}.jsonl")
}
/// Month part of a journal file name, or None when the name does not look
/// like a journal file ("events-202608.jsonl" -> "202608").
pub fn month_from_file_name(name: &str) -> Option<String> {
let rest = name.strip_prefix("events-")?.strip_suffix(".jsonl")?;
(rest.len() == 6 && rest.chars().all(|c| c.is_ascii_digit())).then(|| rest.to_string())
}
/// Sorted journal files of a directory, oldest first.
pub fn journal_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.and_then(month_from_file_name)
.is_some()
})
.collect()
})
.unwrap_or_default();
files.sort();
files
}
/// Parse one journal file into events; corrupt lines are skipped silently.
pub fn parse_file(path: &Path) -> Vec<Event> {
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::<Event>(l).ok())
.collect()
}
/// Read events from a directory in chronological order, limited to the most
/// recent `limit` events when limit > 0.
pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
let mut out: Vec<Event> = Vec::new();
for f in journal_files(dir).iter().rev() {
let mut evs = parse_file(f);
evs.reverse(); // newest first within one file
out.extend(evs);
if limit > 0 && out.len() >= limit {
break;
}
}
if limit > 0 {
out.truncate(limit);
}
out.reverse(); // chronological order
out
}
/// Append one event as a single JSON line to the journal file for the
/// event's month (created under `dir`). Returns the file written to.
/// Retries briefly when the file is locked (Windows).
pub fn append(dir: &Path, event: &Event) -> anyhow::Result<PathBuf> {
let path = dir.join(file_name_for_month(&month_of(&event.ts)));
append_to(&path, event)?;
Ok(path)
}
/// Append one event to a specific journal file.
pub fn append_to(path: &Path, event: &Event) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = serde_json::to_string(event)?;
let mut last_err: Option<std::io::Error> = None;
for attempt in 0..5 {
match std::fs::OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
return Ok(());
}
Err(e) => {
last_err = Some(e);
if attempt < 4 {
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
}
}
}
}
Err(anyhow::anyhow!(
"cannot append to journal {}: {}",
path.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
/// Append-only journal living in a directory (the state directory).
pub struct EventLog {
dir: PathBuf,
/// Cached (month, path) pair to avoid re-computing the file per event.
current: Option<(String, PathBuf)>,
}
impl EventLog {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
current: None,
}
}
/// Directory the journal writes into.
pub fn dir(&self) -> &Path {
&self.dir
}
/// Path of the journal file for the current month.
pub fn current_file(&mut self) -> PathBuf {
let now = crate::installers::now_rfc3339();
self.file_for(&month_of(&now))
}
fn file_for(&mut self, month: &str) -> PathBuf {
if let Some((m, p)) = &self.current {
if m == month {
return p.clone();
}
}
let p = self.dir.join(file_name_for_month(month));
self.current = Some((month.to_string(), p.clone()));
p
}
/// Append one event as a single JSON line; returns the file written to.
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
let month = month_of(&event.ts);
let path = self.file_for(&month);
append_to(&path, event)?;
Ok(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_roundtrip() {
let ev = Event::now(EventKind::Start)
.with_agent("claude-code")
.with_pid(42)
.with_session("s1")
.with_env_keys(vec!["OPENAI_KEY".to_string()])
.with_args(vec!["--dangerously-skip-permissions".to_string()]);
let json = serde_json::to_string(&ev).unwrap();
assert!(!json.contains('\n'));
let back: Event = serde_json::from_str(&json).unwrap();
assert_eq!(back, ev);
assert_eq!(back.agent.as_deref(), Some("claude-code"));
assert_eq!(back.pid, Some(42));
// Absent optional fields are omitted from the line.
assert!(!json.contains("duration_s"));
assert!(!json.contains("reason"));
}
#[test]
fn env_keys_are_sorted_and_never_values() {
let mut env = BTreeMap::new();
env.insert("ZEBRA".to_string(), "secret-z".to_string());
env.insert("ALPHA".to_string(), "secret-a".to_string());
let keys = env_keys(&env);
assert_eq!(keys, vec!["ALPHA".to_string(), "ZEBRA".to_string()]);
}
#[test]
fn month_helpers() {
assert_eq!(month_of("2026-08-16T13:49:11Z"), "202608");
assert_eq!(file_name_for_month("202608"), "events-202608.jsonl");
assert_eq!(
month_from_file_name("events-202608.jsonl"),
Some("202608".to_string())
);
assert_eq!(month_from_file_name("state.json"), None);
assert_eq!(month_from_file_name("events-202608.jsonl.bak"), None);
}
#[test]
fn append_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut log = EventLog::new(dir.path());
let ev = Event::now(EventKind::Run)
.with_agent("aider")
.with_exit_code(0)
.with_duration(48);
let path = log.append(&ev).unwrap();
assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some(file_name_for_month(&month_of(&ev.ts)).as_str())
);
let read = parse_file(&path);
assert_eq!(read.len(), 1);
assert_eq!(read[0], ev);
}
#[test]
fn corrupt_lines_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let mut log = EventLog::new(dir.path());
let ev = Event::now(EventKind::Doctor);
log.append(&ev).unwrap();
// Corrupt the file with garbage lines around a valid one.
let path = log.current_file();
let mut text = std::fs::read_to_string(&path).unwrap();
text.push_str("ceci n'est pas du json
");
text.push_str("
");
std::fs::write(&path, text).unwrap();
let read = parse_file(&path);
assert_eq!(read.len(), 1);
assert_eq!(read[0].kind, EventKind::Doctor);
}
#[test]
fn read_events_spans_months_and_respects_limit() {
let dir = tempfile::tempdir().unwrap();
// Two monthly files, one unrelated file.
std::fs::write(
dir.path().join("events-202607.jsonl"),
r#"{"ts":"2026-07-10T10:00:00Z","kind":"install","agent":"a"}"#,
)
.unwrap();
std::fs::write(
dir.path().join("events-202608.jsonl"),
concat!(
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"b"}"#,
"\n",
r#"{"ts":"2026-08-02T10:00:00Z","kind":"stop","agent":"b"}"#,
"\n",
),
)
.unwrap();
std::fs::write(dir.path().join("state.json"), "{}").unwrap();
let files = journal_files(dir.path());
assert_eq!(files.len(), 2);
assert!(files[0].to_string_lossy().ends_with("events-202607.jsonl"));
let all = read_events(dir.path(), 0);
assert_eq!(all.len(), 3);
assert_eq!(all[0].ts, "2026-07-10T10:00:00Z");
assert_eq!(all[2].ts, "2026-08-02T10:00:00Z");
let limited = read_events(dir.path(), 2);
assert_eq!(limited.len(), 2);
assert_eq!(limited[0].ts, "2026-08-01T10:00:00Z");
assert_eq!(limited[1].ts, "2026-08-02T10:00:00Z");
}
}
+1664
View File
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
//! Structured command history: one JSONL file per REPL session under
//! history/<session>.jsonl. Replaces the flat history.txt (imported once).
use crate::app::App;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryEntry {
/// RFC 3339 timestamp.
pub ts: String,
pub cmd: String,
/// "am" (agent-manager command) or "shell" (system gateway command).
#[serde(default = "kind_am")]
pub kind: String,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub agent: Option<String>,
}
fn kind_am() -> String {
"am".to_string()
}
impl HistoryEntry {
pub fn now(cmd: impl Into<String>) -> Self {
HistoryEntry {
ts: crate::installers::now_rfc3339(),
cmd: cmd.into(),
kind: "am".to_string(),
cwd: None,
exit_code: None,
duration_ms: None,
agent: None,
}
}
pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
self.kind = kind.into();
self
}
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
pub fn with_exit_code(mut self, code: Option<i32>) -> Self {
self.exit_code = code;
self
}
pub fn with_duration_ms(mut self, ms: u64) -> Self {
self.duration_ms = Some(ms);
self
}
pub fn with_agent(mut self, agent: Option<String>) -> Self {
self.agent = agent;
self
}
}
/// Directory holding the per-session history files.
pub fn dir(app: &App) -> PathBuf {
app.events_dir().join("history")
}
/// Append one entry to the session file of a REPL session.
pub fn record(app: &App, session: &str, entry: &HistoryEntry) -> Result<()> {
let d = dir(app);
std::fs::create_dir_all(&d).ok();
let path = d.join(format!("{session}.jsonl"));
let line = serde_json::to_string(entry)?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("cannot open history file {}", path.display()))?;
use std::io::Write;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
Ok(())
}
/// One-shot migration: import the flat history.txt into the structured
/// directory. Returns the number of imported entries.
pub fn migrate(app: &App) -> Result<usize> {
let txt = app.paths.state_file.with_file_name("history.txt");
if !txt.exists() {
return Ok(0);
}
let d = dir(app);
let already = d.join("migrated.jsonl").exists();
if already {
return Ok(0);
}
let text = std::fs::read_to_string(&txt)
.with_context(|| format!("cannot read {}", txt.display()))?;
let mut count = 0;
let mut previous: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Déduplication des répétitions consécutives (quick win #13).
if previous.as_deref() == Some(trimmed) {
continue;
}
previous = Some(trimmed.to_string());
let entry = HistoryEntry::now(trimmed.to_string())
.with_kind("am".to_string())
.with_cwd(None);
record(app, "migrated", &entry)?;
count += 1;
}
Ok(count)
}
/// Expand a history reference against the most recent entries
/// (most recent first): !! (last), !N (the Nth most recent), !prefix (the
/// most recent command starting with prefix) and ^old^new (substitution in
/// the last command). Returns None when the line is not a reference.
pub fn expand(entries: &[HistoryEntry], line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed == "!!" {
return entries.first().map(|e| e.cmd.clone());
}
if let Some(rest) = trimmed.strip_prefix('!') {
if !rest.is_empty() {
if let Ok(n) = rest.parse::<usize>() {
if n >= 1 {
return entries.get(n - 1).map(|e| e.cmd.clone());
}
return None;
}
return entries
.iter()
.find(|e| e.cmd.starts_with(rest))
.map(|e| e.cmd.clone());
}
}
if let Some(body) = trimmed.strip_prefix('^') {
if let Some((old, new)) = body.split_once('^') {
if !old.is_empty() {
let last = entries.first()?;
return Some(last.cmd.replacen(old, new, 1));
}
}
}
None
}
/// History files of a directory, oldest first.
pub fn files(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
.collect()
})
.unwrap_or_default();
out.sort();
out
}
/// Read all entries across sessions, most recent first. Corrupt lines are
/// skipped silently.
pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
let d = dir(app);
let mut out: Vec<HistoryEntry> = Vec::new();
for f in files(&d).iter().rev() {
if let Ok(text) = std::fs::read_to_string(f) {
let mut entries: Vec<HistoryEntry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
.collect();
entries.reverse(); // newest first within one file
out.extend(entries);
if limit > 0 && out.len() >= limit {
break;
}
}
}
if limit > 0 {
out.truncate(limit);
}
out
}
/// Parse the session id from a history file name ("20260815_143926.jsonl").
pub fn session_of_file(path: &Path) -> Option<String> {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.trim_end_matches(".jsonl").to_string())
}
/// Corrupt-history guard: a session file that cannot be parsed is reported,
/// never fatal.
pub fn validate_file(path: &Path) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read history file {}", path.display()))?;
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<HistoryEntry>(line).map_err(|e| {
anyhow!("history file {} line {} is corrupt: {e}", path.display(), i + 1)
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn test_app(tag: &str) -> 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,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut 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;
let _ = tag;
app
}
#[test]
fn record_and_read_back() {
let app = test_app("hist");
record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
record(
&app,
"s1",
&HistoryEntry::now("ls").with_kind("shell".to_string()).with_duration_ms(12),
)
.unwrap();
let all = read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "ls", "most recent first");
assert_eq!(all[0].kind, "shell");
assert_eq!(all[0].duration_ms, Some(12));
assert_eq!(all[1].cmd, "install jcode");
}
#[test]
fn migration_imports_flat_file_once() {
let app = test_app("mig");
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
start group:dev
").unwrap();
assert_eq!(migrate(&app).unwrap(), 2);
assert_eq!(migrate(&app).unwrap(), 0, "second run is a no-op");
let all = read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "start group:dev");
}
#[test]
fn corrupt_lines_are_skipped() {
let app = test_app("corrupt");
record(&app, "s1", &HistoryEntry::now("list")).unwrap();
// Append garbage to the session file.
let d = dir(&app);
let f = d.join("s1.jsonl");
let mut text = std::fs::read_to_string(&f).unwrap();
text.push_str("not json at all
");
std::fs::write(&f, text).unwrap();
let all = read_all(&app, 0);
assert_eq!(all.len(), 1);
assert!(validate_file(&f).is_err());
}
#[test]
fn session_of_file_extracts_id() {
assert_eq!(
session_of_file(Path::new("20260815_143926.jsonl")),
Some("20260815_143926".to_string())
);
}
#[test]
fn expand_history_references() {
let entries = vec![
HistoryEntry::now("install jcode"),
HistoryEntry::now("start group:dev"),
HistoryEntry::now("list --all"),
];
assert_eq!(expand(&entries, "!!").as_deref(), Some("install jcode"));
assert_eq!(expand(&entries, "!2").as_deref(), Some("start group:dev"));
assert_eq!(expand(&entries, "!9"), None, "out of range");
assert_eq!(expand(&entries, "!li").as_deref(), Some("list --all"));
assert_eq!(
expand(&entries, "^jcode^aider").as_deref(),
Some("install aider")
);
assert_eq!(expand(&entries, "plain line"), None);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Hooks: user-defined commands run around agent lifecycle events
//! (on_install, on_start, on_stop, on_update). Global settings.hooks run
//! first, then the hooks of the project profile containing the cwd.
use crate::app::App;
use crate::runner::SystemRunner;
use std::collections::BTreeMap;
use std::path::Path;
/// Run every hook registered for an event, in order. Failures are warnings
/// and never break the main command; --dry-run announces them without
/// executing anything.
pub fn run_hooks(app: &App, hook: &str, cwd: &Path) {
let mut cmds: Vec<String> = Vec::new();
if let Some(global) = app.config.settings.hooks.get(hook) {
cmds.extend(global.iter().cloned());
}
if let Some((_, profile)) = crate::projects::profile_for(app, cwd) {
if let Some(list) = profile.hooks.get(hook) {
cmds.extend(list.iter().cloned());
}
}
if cmds.is_empty() {
return;
}
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
for cmd in &cmds {
let tokens = shell_words::split(cmd).unwrap_or_else(|_| vec![cmd.clone()]);
if tokens.is_empty() {
continue;
}
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
app.log.info(&format!("hook {hook}: {cmd}"));
if let Err(e) = crate::installers::sh(&runner, &prog, &args, &BTreeMap::new(), Some(cwd)) {
app.log.warn(&format!("hook {hook} failed: {e:#}"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with(dir: &std::path::Path) -> App {
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut 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
}
#[test]
fn hook_runs_and_writes_marker() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let marker = dir.join("marker.txt");
let mut app = app_with(&dir);
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
run_hooks(&app, "on_start", &dir);
assert!(marker.exists(), "hook a ecrit le marqueur");
}
#[test]
fn failing_hook_warns_without_panicking() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let mut app = app_with(&dir);
app.config.settings.hooks.insert("on_start".to_string(), vec!["sh -c exit 3".to_string()]);
run_hooks(&app, "on_start", &dir); // ne panique pas
run_hooks(&app, "on_update", &dir); // aucun hook : no-op
}
#[test]
fn dry_run_announces_without_executing() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let marker = dir.join("marker2.txt");
let mut app = app_with(&dir);
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
app.cli.dry_run = true;
run_hooks(&app, "on_start", &dir);
assert!(!marker.exists(), "dry-run ne doit rien executer");
}
#[test]
fn settings_hooks_parse_from_yaml() {
let cfg: crate::config::Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n hooks:\n on_start: [\"sh -c echo hi\"]\nagents: []\n",
)
.unwrap();
assert_eq!(
cfg.settings.hooks["on_start"],
vec!["sh -c echo hi".to_string()]
);
}
}
+46
View File
@@ -76,6 +76,9 @@ pub fn install(
format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display())
})?;
}
// Guard against installing something that is not an executable for this
// platform (wrong architecture, an unextracted archive, ...).
verify_executable(&dest_bin)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -88,6 +91,49 @@ pub fn install(
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_executable_accepts_pe_and_rejects_elf() {
let dir = tempfile::tempdir().unwrap();
let pe = dir.path().join("a.exe");
std::fs::write(&pe, b"MZwhatever").unwrap();
assert!(verify_executable(&pe).is_ok());
let elf = dir.path().join("b");
std::fs::write(&elf, b"ELF").unwrap();
let err = verify_executable(&elf).unwrap_err();
assert!(err.to_string().contains("not a"), "{err}");
}
}
/// Check that the downloaded file is an executable of this platform.
/// Detects the classic Windows "not compatible" (error 216) and Linux
/// "exec format error" failures before the agent is ever started.
fn verify_executable(path: &std::path::Path) -> Result<()> {
let mut head = [0u8; 4];
let n = std::fs::File::open(path)
.and_then(|mut f| std::io::Read::read(&mut f, &mut head))
.unwrap_or(0);
#[cfg(windows)]
let ok = n >= 2 && &head[0..2] == b"MZ";
#[cfg(target_os = "macos")]
let ok = (n >= 4 && (&head[0..4] == b"\xcf\xfa\xed\xfe" || &head[0..4] == b"\xfe\xed\xfa\xcf"))
|| (n >= 4 && &head[0..4] == b"\xca\xfe\xba\xbe");
#[cfg(all(unix, not(target_os = "macos")))]
let ok = n >= 4 && &head[0..4] == b"\x7fELF";
if ok {
return Ok(());
}
bail!(
"downloaded file {} is not a {} executable (magic bytes {:02x?}) — the release asset is probably for another platform or is an installer/archive",
path.display(),
std::env::consts::OS,
&head[..n]
)
}
/// Latest release tag (without the leading "v").
pub fn latest(
_app: &App,
+12 -1
View File
@@ -97,7 +97,7 @@ pub fn install(
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
bins.push(dest);
} else {
let shim = bindir.join(exe_name(&bin_name));
let shim = bindir.join(shim_name(&bin_name));
write_shim(agent, &shim, &repo_dir)?;
bins.push(shim);
}
@@ -132,6 +132,17 @@ pub fn install(
Ok(InstallOutcome { bins, version })
}
/// File name of the shim for an agent: a .cmd script on Windows (a batch
/// file renamed to .exe would fail with "Unsupported 16-Bit Application"),
/// a plain executable name elsewhere.
fn shim_name(bin_name: &str) -> String {
if cfg!(windows) {
format!("{bin_name}.cmd")
} else {
bin_name.to_string()
}
}
/// Create a small script that runs the agent from inside the repository
/// directory (with node_modules/.bin on PATH for Node-based projects).
fn write_shim(agent: &AgentDef, shim: &Path, repo_dir: &Path) -> Result<()> {
+33 -15
View File
@@ -31,6 +31,10 @@ fn venv_python(venv: &Path) -> PathBuf {
}
/// Shared implementation for pip (use_uv=false) and uv (use_uv=true).
///
/// The uv path creates the venv with 'uv venv': uv downloads and manages a
/// Python interpreter itself when no working system Python exists, so uv
/// installs do not depend on a system Python at all.
pub fn install(
app: &App,
agent: &AgentDef,
@@ -42,7 +46,6 @@ pub fn install(
.package
.as_deref()
.ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?;
let python = find_python(runner)?;
let root = install_root(app, &agent.name);
let venv = root.join("venv");
ensure_dir(app, &root)?;
@@ -51,25 +54,26 @@ pub fn install(
.with_context(|| format!("cannot remove {}", venv.display()))?;
}
let empty = BTreeMap::new();
sh(
runner,
&python.display().to_string(),
&[
"-m".to_string(),
"venv".to_string(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
if use_uv {
let uv = require_tool(
runner,
"uv",
"https://docs.astral.sh/uv — e.g. 'pip install uv'",
)?;
sh(
runner,
&uv.display().to_string(),
&[
"venv".into(),
"--python".into(),
"3.13".into(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
let mut args: Vec<String> = vec![
"pip".into(),
"install".into(),
@@ -80,6 +84,20 @@ pub fn install(
args.extend(method.args.iter().cloned());
sh(runner, &uv.display().to_string(), &args, &method.env, None)?;
} else {
let python = find_python(runner)?;
sh(
runner,
&python.display().to_string(),
&[
"-m".to_string(),
"venv".to_string(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
let mut args: Vec<String> = vec![
"-m".into(),
"pip".into(),
@@ -91,7 +109,7 @@ pub fn install(
sh(runner, &vp.display().to_string(), &args, &method.env, None)?;
}
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&vbin, &expected);
let mut bins = find_bins(&venv_bin_dir(&venv), &expected);
if bins.is_empty() {
bins = find_bins(&bin_dir(app), &expected);
}
+24
View File
@@ -16,16 +16,29 @@ pub mod catalog;
pub mod cli;
pub mod commands;
pub mod config;
pub mod context;
pub mod deps;
pub mod doctor;
pub mod download;
pub mod events;
pub mod help;
pub mod history;
pub mod hooks;
pub mod installers;
pub mod nav;
pub mod output;
pub mod probe;
pub mod projects;
pub mod ps;
pub mod process;
pub mod repl;
pub mod runner;
pub mod secrets;
pub mod sessions;
pub mod shell;
pub mod state;
pub mod tables;
pub mod theme;
pub mod toolchain;
pub mod version;
@@ -33,6 +46,17 @@ use clap::Parser;
/// CLI entry point used by the am binary. Returns the process exit code.
pub fn main_entry() -> i32 {
// Enable ANSI escape sequences on Windows consoles (best effort): the
// colored crate and the theme palettes both emit raw VT codes.
#[cfg(windows)]
let _ = colored::control::set_virtual_terminal(true);
// Nushell-style help: 'am -h', 'am install -h', 'am ls --help', ... all
// show the custom help before clap gets a chance to parse the line.
let args: Vec<String> = std::env::args().collect();
if let Some(code) = help::intercept(&args) {
return code;
}
let cli = cli::Cli::parse();
match app::App::from_cli(cli) {
Ok(app) => match commands::execute(&app) {
+338
View File
@@ -0,0 +1,338 @@
//! Nushell-inspired navigation helpers: a boxed table listing of files and
//! directories (ls), and directory/path completion for Tab.
use crate::tables::{Cell, DataTable};
use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
/// Human-readable byte size, like Nushell ("512 B", "1.2 KB", "3.4 MB").
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
const GB: f64 = 1024.0 * 1024.0 * 1024.0;
let b = bytes as f64;
if b < KB {
format!("{bytes} B")
} else if b < MB {
format!("{:.1} KB", b / KB)
} else if b < GB {
format!("{:.1} MB", b / MB)
} else {
format!("{:.1} GB", b / GB)
}
}
/// Recursive size of a directory, tightly bounded: beyond the entry cap the
/// size is reported as unknown (None). The cap is deliberately small so a
/// listing stays snappy even when it contains huge trees.
pub fn dir_size(path: &Path) -> Option<u64> {
const CAP: usize = 500;
let mut total: u64 = 0;
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
let mut visited: usize = 0;
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).ok()?;
for entry in rd.flatten() {
visited += 1;
if visited > CAP {
return None;
}
let p = entry.path();
match std::fs::symlink_metadata(&p) {
Ok(m) if m.is_dir() => stack.push(p),
Ok(m) if m.is_file() => {
total = total.saturating_add(m.len());
}
_ => {}
}
}
}
Some(total)
}
fn modified_text(meta: &std::fs::Metadata) -> String {
meta.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Local> = t.into();
dt.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_else(|| "—".to_string())
}
fn paint(s: &str, color: bool, style: u8) -> String {
if !color {
return s.to_string();
}
match style {
1 => s.blue().bold().to_string(), // directory
2 => s.green().to_string(), // executable
3 => s.cyan().to_string(), // symlink
4 => s.red().to_string(), // broken symlink
_ => s.to_string(),
}
}
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
let _ = meta;
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref(),
Some("exe") | Some("bat") | Some("cmd") | Some("com")
)
}
}
/// One ls row: (sort key, cells). A single symlink_metadata call per entry
/// (the modification time comes from the same metadata).
fn row_for(p: &Path, color: bool) -> Option<(String, Vec<Cell>)> {
let meta = std::fs::symlink_metadata(p).ok()?;
let file_type = meta.file_type();
let name = p.file_name().and_then(|n| n.to_str())?.to_string();
let (kind, style) = if file_type.is_symlink() {
("symlink", if p.exists() { 3 } else { 4 })
} else if file_type.is_dir() {
("dir", 1)
} else if is_executable(p, &meta) {
("file", 2)
} else {
("file", 0)
};
let (size_text, size_bytes) = if file_type.is_dir() {
match dir_size(p) {
Some(s) => (format_size(s), Some(s)),
None => ("—".to_string(), None),
}
} else {
(format_size(meta.len()), Some(meta.len()))
};
let mut display = name.clone();
if file_type.is_dir() {
display.push('/');
} else if file_type.is_symlink() {
if let Ok(target) = std::fs::read_link(p) {
display.push_str(&format!(" -> {}", target.display()));
}
}
Some((
name.to_lowercase(),
vec![
Cell::str(paint(&display, color, style)),
Cell::str(kind.to_string()),
Cell::bytes(size_bytes.unwrap_or(0), size_text),
Cell::str(modified_text(&meta)),
],
))
}
/// Build the ls table (columns: name, type, size, modified). Directories
/// come first, then alphabetical order.
///
/// Rows are built in parallel: recursive directory sizes dominate the cost,
/// so a listing with many subdirectories stays fast (like Nushell, which
/// also parallelizes its listing).
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
let rd = std::fs::read_dir(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.min(8);
let chunk_size = entries.len().div_ceil(threads).max(1);
let rows: Vec<(String, Vec<Cell>)> = std::thread::scope(|scope| {
let mut handles = Vec::new();
for chunk in entries.chunks(chunk_size) {
let chunk: Vec<PathBuf> = chunk.to_vec();
handles.push(scope.spawn(move || {
chunk
.into_iter()
.filter_map(|p| row_for(&p, color))
.collect::<Vec<_>>()
}));
}
let mut out = Vec::new();
for handle in handles {
if let Ok(mut rows) = handle.join() {
out.append(&mut rows);
}
}
out
});
// Directories first, then alphabetical.
let mut rows = rows;
rows.sort_by(|a, b| {
let a_dir = a.1[1].text == "dir";
let b_dir = b.1[1].text == "dir";
b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0))
});
Ok(DataTable {
columns: vec![
"name".into(),
"type".into(),
"size".into(),
"modified".into(),
],
rows: rows.into_iter().map(|(_, r)| r).collect(),
})
}
/// Render a Nushell-style boxed table of the entries of a path (unbounded
/// width — used by tests; the REPL renders with the terminal width).
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
Ok(ls_table(path, color)?.render(color, 200))
}
/// Completion candidates for a path fragment typed after cd or ls.
///
/// The last segment is matched against the entries of the base directory;
/// directory candidates end with a separator so the next Tab dives one level
/// deeper, like Nushell. With dirs_only, files are excluded (cd).
pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec<String> {
let sep = std::path::MAIN_SEPARATOR;
let alt = if sep == '\\' { '/' } else { '\\' };
let idx = input.rfind(|c| c == sep || c == alt);
let (base_part, stem) = match idx {
Some(i) => (&input[..=i], &input[i + 1..]),
None => ("", input),
};
let use_sep = if input.contains(alt) && !input.contains(sep) {
alt
} else {
sep
};
let base: PathBuf = if base_part.starts_with('~') {
crate::config::expand_path(base_part)
} else if base_part.is_empty() {
base_dir.to_path_buf()
} else {
PathBuf::from(base_part)
};
let mut names: Vec<(String, bool)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&base) {
for entry in rd.flatten() {
let p = entry.path();
let is_dir = p.is_dir();
if dirs_only && !is_dir {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with(stem) {
continue;
}
names.push((name.to_string(), is_dir));
}
}
names.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
names
.into_iter()
.map(|(name, is_dir)| {
let mut full = format!("{base_part}{name}");
if is_dir {
full.push(use_sep);
}
full
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_sizes() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(2048), "2.0 KB");
assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB");
}
#[test]
fn dir_size_is_bounded() {
let dir = tempfile::tempdir().unwrap();
let big = dir.path().join("big");
std::fs::create_dir_all(&big).unwrap();
for i in 0..520 {
std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap();
}
// Beyond the cap the size is unknown, and the walk stays cheap.
assert_eq!(dir_size(&big), None);
let small = dir.path().join("small");
std::fs::create_dir_all(&small).unwrap();
std::fs::write(small.join("a.txt"), "hello").unwrap();
assert_eq!(dir_size(&small), Some(5));
}
#[test]
fn completes_directories_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join("src2")).unwrap();
std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
let cands = path_candidates(dir.path(), "", true);
assert!(cands.iter().any(|c| c == "src\\" || c == "src/"));
assert!(cands.iter().any(|c| c == "src2\\" || c == "src2/"));
assert!(!cands.iter().any(|c| c.contains("readme")));
let cands = path_candidates(dir.path(), "s", true);
assert_eq!(cands.len(), 2, "{cands:?}");
// Files appear for ls completion.
let cands = path_candidates(dir.path(), "", false);
assert!(cands.iter().any(|c| c.contains("readme")));
}
#[test]
fn completes_nested_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src").join("commands")).unwrap();
std::fs::create_dir_all(dir.path().join("src").join("installers")).unwrap();
let sep = std::path::MAIN_SEPARATOR;
let prefix = format!("src{}", sep);
let cands = path_candidates(dir.path(), &format!("src{}co", sep), true);
assert!(cands.iter().any(|c| c == &format!("{prefix}commands{}", sep)));
assert!(!cands.iter().any(|c| c.contains("installers")));
}
#[test]
fn renders_ls_table() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
let table = render_ls(dir.path(), false).unwrap();
assert!(table.contains("╭"));
assert!(table.contains("╰"));
assert!(table.contains("name"));
assert!(table.contains("type"));
assert!(table.contains("modified"));
assert!(table.contains("subdir/"));
assert!(table.contains("file.txt"));
assert!(table.contains("dir"));
assert!(table.contains("file"));
assert!(table.contains("5 B"));
// Every line of the table has the same width.
let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}");
}
}
+270 -13
View File
@@ -143,6 +143,153 @@ impl Logger {
}
}
// ---------------------------------------------------------------------------
// Shared boxed outputs (the Nushell-style "white frame")
// ---------------------------------------------------------------------------
/// Truncate a string to max visible chars with an ellipsis.
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut cut: String = s.chars().take(max.saturating_sub(1)).collect();
cut.push('…');
cut
}
}
/// Build a boxed Nushell-style table (white frame, like ls) from plain rows
/// and render it, fitted to the terminal width and the active theme.
pub fn boxed_table(
headers: Vec<&str>,
rows: Vec<Vec<String>>,
theme: &crate::theme::Theme,
color: bool,
) -> String {
use crate::tables::{Cell, DataTable};
let table = DataTable {
columns: headers.into_iter().map(|h| h.to_string()).collect(),
rows: rows
.into_iter()
.map(|r| r.into_iter().map(Cell::str).collect())
.collect(),
};
table.render_themed(theme, color, terminal_width().unwrap_or(120))
}
/// Render a two-column key/value box (no header row), like the version
/// table. Keys use the theme key style, values the value style.
pub fn kv_table(
rows: &[(String, String)],
theme: &crate::theme::Theme,
color: bool,
) -> String {
let key_w = rows
.iter()
.map(|(k, _)| visible_len(k))
.max()
.unwrap_or(0);
let val_w = rows
.iter()
.map(|(_, v)| visible_len(v))
.max()
.unwrap_or(0);
let border = |left: char, mid: char, right: char| -> String {
let line = format!(
"{left}{}{mid}{}{right}",
"─".repeat(key_w + 2),
"─".repeat(val_w + 2)
);
if color {
theme.frame(&line)
} else {
line
}
};
let paint = |s: &str, color: bool, style: fn(&crate::theme::Theme, &str) -> String| {
if color && !s.contains('\u{1b}') {
style(theme, s)
} else {
s.to_string()
}
};
let bar = if color {
theme.frame("│")
} else {
"│".to_string()
};
let mut out = String::new();
out.push_str(&border('╭', '┬', '╮'));
out.push('\n');
for (k, v) in rows {
let key = paint(k, color, crate::theme::Theme::key);
let val = paint(v, color, crate::theme::Theme::val);
out.push_str(&format!(
"{bar} {} {bar} {} {bar}\n",
pad(key.as_str(), key_w),
pad(val.as_str(), val_w)
));
}
out.push_str(&border('╰', '┴', '╯'));
out.push('\n');
out
}
/// Build the lines of the boxed Tab-completion menu from (candidate,
/// description) rows. Single column when no descriptions exist.
pub fn completion_box(rows: &[(String, String)]) -> Vec<String> {
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
let name_w = rows
.iter()
.map(|(n, _)| n.chars().count())
.max()
.unwrap_or(0)
.max("name".chars().count());
let desc_w = if has_desc {
rows.iter()
.map(|(_, d)| truncate(d, 40).chars().count())
.max()
.unwrap_or(0)
.max("description".chars().count())
} else {
0
};
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
line.push_str(&"─".repeat(name_w + 2));
if has_desc {
line.push(mid);
line.push_str(&"─".repeat(desc_w + 2));
}
line.push(right);
line
};
let mut out = Vec::new();
out.push(border('╭', '┬', '╮'));
if has_desc {
out.push(format!(
"│ {} │ {} │",
pad("name", name_w),
pad("description", desc_w)
));
out.push(border('├', '┼', '┤'));
}
for (name, desc) in rows {
if has_desc {
out.push(format!(
"│ {} │ {} │",
pad(name, name_w),
pad(&truncate(desc, 40), desc_w)
));
} else {
out.push(format!("│ {} │", pad(name, name_w)));
}
}
out.push(border('╰', '┴', '╯'));
out
}
// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------
@@ -213,22 +360,21 @@ impl Table {
}
}
/// Color a status cell for display in tables.
pub fn status_style(status: &str, color: bool) -> String {
let s = status.to_string();
/// Color a status cell for display in tables (theme-aware).
pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String {
if !color {
return s;
return status.to_string();
}
match status {
"running" => s.green().bold().to_string(),
"installed" => s.cyan().to_string(),
"external" => s.yellow().to_string(),
"not-installable" => s.red().to_string(),
"not-installed" => s.dimmed().to_string(),
"ok" => s.green().to_string(),
"warn" => s.yellow().to_string(),
"error" => s.red().bold().to_string(),
_ => s,
"running" => theme.acc(status),
"installed" => theme.inf(status),
"external" => theme.warn(status),
"not-installable" => theme.err(status),
"not-installed" => theme.dimmed(status),
"ok" => theme.ok(status),
"warn" => theme.warn(status),
"error" => theme.err(status),
_ => status.to_string(),
}
}
@@ -239,6 +385,42 @@ pub fn print_json<T: Serialize>(value: &T) {
}
}
/// Width of the terminal attached to stdout, in columns. Returns None when
/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly
/// for tests and scripts.
pub fn terminal_width() -> Option<usize> {
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
if let Ok(w) = v.trim().parse::<usize>() {
return Some(w);
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO,
};
let handle = std::io::stdout().as_raw_handle() as isize;
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 {
let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize;
if width > 0 {
return Some(width);
}
}
None
}
#[cfg(not(windows))]
{
if let Ok(v) = std::env::var("COLUMNS") {
if let Ok(w) = v.trim().parse::<usize>() {
return Some(w);
}
}
None
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
@@ -271,3 +453,78 @@ fn pad(s: &str, width: usize) -> String {
format!("{s}{}", " ".repeat(width - n))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_box_with_descriptions_is_aligned() {
let rows = vec![
("list".to_string(), "list installed agents".to_string()),
("status".to_string(), "show agent state".to_string()),
];
let lines = completion_box(&rows);
assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom
assert!(lines[0].starts_with('╭') && lines[0].contains('┬'));
assert!(lines[0].ends_with('╮'));
assert!(lines[1].contains("name") && lines[1].contains("description"));
assert!(lines[2].starts_with('├'));
assert!(lines[5].starts_with('╰'));
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
assert!(
widths.iter().all(|w| *w == widths[0]),
"unaligned: {lines:?}"
);
assert!(lines[3].contains("list") && lines[3].contains("list installed agents"));
}
#[test]
fn completion_box_without_descriptions_is_single_column() {
let rows = vec![
("src".to_string(), "".to_string()),
("am.exe".to_string(), "".to_string()),
];
let lines = completion_box(&rows);
assert_eq!(lines.len(), 4); // top, 2 rows, bottom
assert!(!lines.iter().any(|l| l.contains("description")));
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]));
}
#[test]
fn kv_table_is_aligned_with_and_without_colors() {
let rows = vec![
("version".to_string(), "0.2.13".to_string()),
("build_os".to_string(), "windows-x86_64".to_string()),
];
for color in [false, true] {
let text = kv_table(&rows, crate::theme::default_theme(), color);
// Compare visible widths (ANSI codes must not shift columns).
let widths: Vec<usize> = text
.lines()
.map(|l| strip_ansi(l).chars().count())
.collect();
assert!(
widths.iter().all(|w| *w == widths[0]),
"unaligned ({color}):\n{text}"
);
}
// Color output really contains ANSI sequences.
let colored = kv_table(&rows, crate::theme::default_theme(), true);
assert!(colored.contains("\x1b["));
}
#[test]
fn boxed_table_has_a_frame() {
let text = boxed_table(
vec!["NAME", "STATUS"],
vec![vec!["claude-code".to_string(), "external".to_string()]],
crate::theme::default_theme(),
false,
);
assert!(text.contains("╭") && text.contains("╰"));
assert!(text.contains("claude-code"));
assert!(text.contains("NAME"));
}
}
+4
View File
@@ -98,6 +98,7 @@ pub fn spawn_background(
program: &Path,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
) -> Result<u32> {
let log = agent_log_path(app, agent_name);
if app.dry_run() {
@@ -121,6 +122,9 @@ pub fn spawn_background(
.envs(env)
.stdout(Stdio::from(file.try_clone()?))
.stderr(Stdio::from(file));
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
+185
View File
@@ -0,0 +1,185 @@
//! Project aggregation: which agent works on which project. Derived from
//! the event journal (project field), with a best-effort cache written to
//! projects.json (refreshed by 'am doctor --fix').
use crate::app::App;
use crate::config::ProjectProfile;
use crate::events::{self, EventKind};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize)]
pub struct ProjectSummary {
pub root: String,
pub agents: Vec<String>,
pub dominant: Option<String>,
pub sessions: u64,
pub launches: u64,
pub total_duration_s: u64,
pub last_activity: Option<String>,
}
/// Cache file (regenerated on read; deleted by doctor --fix).
pub fn cache_file(app: &App) -> PathBuf {
app.events_dir().join("projects.json")
}
/// Aggregate the journal into per-project summaries, sorted by last
/// activity (most recent first). Also refreshes the cache file.
pub fn aggregate(app: &App) -> Vec<ProjectSummary> {
let mut map: BTreeMap<String, ProjectSummary> = BTreeMap::new();
let mut agent_counts: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
let mut sessions: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for ev in events::read_events(&app.events_dir(), 0) {
let Some(project) = ev.project else {
continue;
};
let s = map
.entry(project.clone())
.or_insert_with(|| ProjectSummary {
root: project.clone(),
..Default::default()
});
if let Some(a) = ev.agent {
*agent_counts
.entry(project.clone())
.or_default()
.entry(a)
.or_default() += 1;
}
if let Some(sid) = ev.session {
sessions.entry(project.clone()).or_default().insert(sid);
}
match ev.kind {
EventKind::Start | EventKind::Run => s.launches += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
s.total_duration_s += d;
}
if ev.ts > s.last_activity.clone().unwrap_or_default() {
s.last_activity = Some(ev.ts);
}
}
for (p, s) in map.iter_mut() {
s.agents = agent_counts
.get(p)
.map(|c| c.keys().cloned().collect())
.unwrap_or_default();
s.agents.sort();
s.dominant = agent_counts
.get(p)
.and_then(|c| c.iter().max_by_key(|(_, n)| *n).map(|(a, _)| a.clone()));
s.sessions = sessions.get(p).map(|set| set.len() as u64).unwrap_or(0);
}
let mut out: Vec<ProjectSummary> = map.into_values().collect();
out.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
// Best-effort cache (readable by scripts and backups).
if let Ok(text) = serde_json::to_string_pretty(&out) {
let _ = std::fs::write(cache_file(app), text);
}
out
}
/// The project profile whose root matches cwd (the longest root wins).
pub fn profile_for<'a>(app: &'a App, cwd: &Path) -> Option<(&'a str, &'a ProjectProfile)> {
let mut best: Option<(&str, &ProjectProfile)> = None;
for (name, p) in &app.config.projects {
let root = crate::config::expand_path(&p.root);
let matches = cwd.starts_with(&root) || cwd == root;
if matches {
let better = best
.map(|(_, b)| {
let broot = crate::config::expand_path(&b.root);
root.components().count() > broot.components().count()
})
.unwrap_or(true);
if better {
best = Some((name.as_str(), p));
}
}
}
best
}
/// Default agent of the project containing cwd (issue #30 helper).
pub fn default_agent_for_dir(app: &App, cwd: &Path) -> Option<String> {
profile_for(app, cwd)
.and_then(|(_, p)| p.default_agent.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with_events(lines: &[&str]) -> 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,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut 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;
let month = chrono::Utc::now().format("%Y%m");
let path = dir.join(format!("events-{month}.jsonl"));
let mut text = String::new();
for l in lines {
text.push_str(l);
text.push('\n');
}
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, text).unwrap();
app
}
#[test]
fn aggregates_projects_from_events() {
let app = app_with_events(&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#,
r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#,
]);
let out = aggregate(&app);
assert_eq!(out.len(), 2);
let api = out.iter().find(|s| s.root == "/home/x/api").unwrap();
assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]);
assert_eq!(api.dominant.as_deref(), Some("claude-code"));
assert_eq!(api.sessions, 3);
assert_eq!(api.launches, 3);
assert_eq!(api.total_duration_s, 35);
assert!(api.last_activity.is_some());
assert!(cache_file(&app).exists(), "cache written");
}
#[test]
fn profile_matching_prefers_longest_root() {
let app = app_with_events(&[]);
let tmp = std::env::temp_dir();
let api = tmp.join("p1-api");
let sub = api.join("src");
let _ = (api, sub);
// Ce test est complete dans l'integration (config avec profils) ;
// ici on verifie juste l'absence de profil par defaut.
assert!(default_agent_for_dir(&app, &std::env::temp_dir()).is_none());
}
}
+218
View File
@@ -0,0 +1,218 @@
//! ps: Nushell-style process table.
//!
//! Windows uses the Toolhelp32 snapshot API (pid, ppid, name, threads, cpu
//! time, working set); Unix reads /proc.
use crate::tables::{Cell, DataTable};
/// Columns: pid, ppid, name, cpu, mem, threads.
fn columns() -> Vec<String> {
vec![
"pid".into(),
"ppid".into(),
"name".into(),
"cpu".into(),
"mem".into(),
"threads".into(),
]
}
/// Build the process table.
pub fn ps_table() -> DataTable {
#[cfg(windows)]
let rows = windows_rows();
#[cfg(unix)]
let rows = unix_rows();
#[cfg(not(any(windows, unix)))]
let rows = Vec::new();
let mut rows = rows;
rows.sort_by(|a, b| {
a[0]
.num
.unwrap_or(0.0)
.partial_cmp(&b[0].num.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
DataTable {
columns: columns(),
rows,
}
}
// ---------------------------------------------------------------------------
// Windows: Toolhelp32 snapshot
// ---------------------------------------------------------------------------
#[cfg(windows)]
fn windows_rows() -> Vec<Vec<Cell>> {
use crate::nav::format_size;
use std::mem::size_of;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::ProcessStatus::{
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION,
};
let mut rows: Vec<Vec<Cell>> = Vec::new();
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return rows;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = size_of::<PROCESSENTRY32W>() as u32;
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 {
let pid = entry.th32ProcessID;
let ppid = entry.th32ParentProcessID;
let threads = entry.cntThreads;
let mut name = String::new();
for &unit in entry.szExeFile.iter() {
if unit == 0 {
break;
}
if let Some(c) = char::from_u32(unit as u32) {
name.push(c);
}
}
// CPU time and memory need a process handle; some system processes
// refuse — those cells stay "—".
let mut cpu = "—".to_string();
let mut mem = "—".to_string();
let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) };
if handle != 0 {
let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
if unsafe {
K32GetProcessMemoryInfo(
handle,
&mut counters,
size_of::<PROCESS_MEMORY_COUNTERS>() as u32,
)
} != 0
{
mem = format_size(counters.WorkingSetSize as u64);
}
let (mut created, mut exited, mut kernel, mut user): (
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
) = unsafe {
(
std::mem::zeroed(),
std::mem::zeroed(),
std::mem::zeroed(),
std::mem::zeroed(),
)
};
if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } != 0 {
let ticks = (filetime_u64(&kernel) + filetime_u64(&user)) as f64 / 10_000_000.0;
cpu = format!("{ticks:.0}s");
}
unsafe { CloseHandle(handle) };
}
rows.push(vec![
Cell::num(pid as f64, pid.to_string()),
Cell::num(ppid as f64, ppid.to_string()),
Cell::str(name),
Cell::str(cpu),
Cell::str(mem),
Cell::num(threads as f64, threads.to_string()),
]);
ok = unsafe { Process32NextW(snapshot, &mut entry) };
}
unsafe { CloseHandle(snapshot) };
rows
}
#[cfg(windows)]
fn filetime_u64(ft: &windows_sys::Win32::Foundation::FILETIME) -> u64 {
((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
}
// ---------------------------------------------------------------------------
// Unix: /proc
// ---------------------------------------------------------------------------
#[cfg(unix)]
fn unix_rows() -> Vec<Vec<Cell>> {
use crate::nav::format_size;
let page_size = page_size();
let mut rows: Vec<Vec<Cell>> = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return rows;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Ok(pid) = name.parse::<f64>() else {
continue;
};
let base = entry.path();
let Ok(stat) = std::fs::read_to_string(base.join("stat")) else {
continue;
};
// The comm field is in parentheses and may contain spaces.
let Some(close) = stat.rfind(')') else {
continue;
};
let comm = stat[stat.find('(').unwrap_or(0) + 1..close].to_string();
let rest: Vec<&str> = stat[close + 2..].split_whitespace().collect();
if rest.len() < 22 {
continue;
}
// rest[0]=state, rest[1]=ppid, rest[11]=utime, rest[12]=stime,
// rest[17]=num_threads, rest[21]=rss (pages).
let ppid = rest[1].parse::<f64>().unwrap_or(0.0);
let utime = rest[11].parse::<f64>().unwrap_or(0.0);
let stime = rest[12].parse::<f64>().unwrap_or(0.0);
let threads = rest[17].parse::<f64>().unwrap_or(0.0);
let rss_pages = rest[21].parse::<u64>().unwrap_or(0);
let cpu = (utime + stime) / 100.0;
rows.push(vec![
Cell::num(pid, pid.to_string()),
Cell::num(ppid, ppid.to_string()),
Cell::str(comm),
Cell::str(format!("{cpu:.0}s")),
Cell::str(format_size(rss_pages * page_size)),
Cell::num(threads, threads.to_string()),
]);
}
rows
}
#[cfg(unix)]
fn page_size() -> u64 {
// Standard page size; RSS in pages * 4096 is close enough for display.
4096
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ps_produces_a_table() {
let table = ps_table();
assert_eq!(
table.columns,
vec!["pid", "ppid", "name", "cpu", "mem", "threads"]
);
assert!(!table.rows.is_empty(), "process table should not be empty");
// Our own pid is in the table.
let me = std::process::id().to_string();
assert!(
table.rows.iter().any(|r| r[0].text == me),
"our pid should be listed"
);
// Rows have as many cells as columns.
assert!(table.rows.iter().all(|r| r.len() == 6));
}
}
+1560 -71
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
//! Secrets: OS keyring storage (never plaintext in config, logs or env_keys).
use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
pub trait SecretStore {
fn set(&self, key: &str, value: &str) -> Result<()>;
fn get(&self, key: &str) -> Result<Option<String>>;
fn remove(&self, key: &str) -> Result<bool>;
fn list(&self) -> Result<Vec<String>>;
}
const SERVICE: &str = "agent-manager";
const INDEX_KEY: &str = "__index__";
/// OS keyring backend (Windows Credential Manager, macOS Keychain, Linux
/// Secret Service when available).
pub struct KeyringStore;
impl SecretStore for KeyringStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, key)?;
entry.set_password(value).map_err(|e| anyhow!("keyring: {e}"))?;
let mut idx = self.list().unwrap_or_default();
if !idx.iter().any(|k| k == key) {
idx.push(key.to_string());
}
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
let _ = index.set_password(&idx.join(","));
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
let entry = keyring::Entry::new(SERVICE, key)?;
match entry.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(anyhow!("keyring: {e}")),
}
}
fn remove(&self, key: &str) -> Result<bool> {
let entry = keyring::Entry::new(SERVICE, key)?;
match entry.delete_credential() {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(e) => Err(anyhow!("keyring: {e}")),
}
}
fn list(&self) -> Result<Vec<String>> {
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
match index.get_password() {
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(e) => Err(anyhow!("keyring: {e}")),
}
}
}
/// The production store.
pub fn store() -> KeyringStore {
KeyringStore
}
/// Key for an agent-scoped secret name: "claude-code/OPENAI_KEY".
pub fn key_for(agent: &str, name: &str) -> String {
format!("{agent}/{name}")
}
/// Resolve "@secret" placeholders in an env map: a value equal to
/// "@secret" pulls the secret named by the variable name for this agent.
/// Returns human warnings (names only — values never leak).
pub fn resolve_env_secrets(
store: &dyn SecretStore,
agent: &str,
env: &mut BTreeMap<String, String>,
) -> Vec<String> {
let mut warnings = Vec::new();
let keys: Vec<String> = env.keys().cloned().collect();
for k in keys {
let placeholder = env.get(&k).map(|v| v == "@secret").unwrap_or(false);
if !placeholder {
continue;
}
let key = key_for(agent, &k);
match store.get(&key) {
Ok(Some(v)) => {
env.insert(k.clone(), v);
}
Ok(None) => warnings.push(format!(
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
)),
Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")),
}
}
warnings
}
/// In-memory store for tests.
#[derive(Default)]
pub struct MockStore {
pub map: std::sync::Mutex<BTreeMap<String, String>>,
}
impl SecretStore for MockStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.map.lock().unwrap().insert(key.to_string(), value.to_string());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.map.lock().unwrap().get(key).cloned())
}
fn remove(&self, key: &str) -> Result<bool> {
Ok(self.map.lock().unwrap().remove(key).is_some())
}
fn list(&self) -> Result<Vec<String>> {
Ok(self.map.lock().unwrap().keys().cloned().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_roundtrip() {
let s = MockStore::default();
s.set("claude-code/OPENAI_KEY", "sk-123").unwrap();
assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap().as_deref(), Some("sk-123"));
assert_eq!(s.list().unwrap(), vec!["claude-code/OPENAI_KEY".to_string()]);
assert!(s.remove("claude-code/OPENAI_KEY").unwrap());
assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap(), None);
}
#[test]
fn resolves_placeholders_and_warns() {
let s = MockStore::default();
s.set("aider/TOKEN", "secret-value").unwrap();
let mut env = BTreeMap::new();
env.insert("TOKEN".to_string(), "@secret".to_string());
env.insert("PLAIN".to_string(), "kept".to_string());
env.insert("MISSING".to_string(), "@secret".to_string());
let warnings = resolve_env_secrets(&s, "aider", &mut env);
assert_eq!(env.get("TOKEN").map(String::as_str), Some("secret-value"));
assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept"));
assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret"));
assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}");
assert!(!warnings[0].contains("secret-value"));
}
}
+339
View File
@@ -0,0 +1,339 @@
//! Session registry: one record per agent session (background start/stop
//! cycles and interactive REPL sessions). sessions.json is an index derived
//! from the event journal; it can be rebuilt with 'am doctor --fix'.
use crate::app::App;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
pub const SESSIONS_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionRecord {
pub id: String,
/// "agent" or "repl".
#[serde(default = "kind_agent")]
pub kind: String,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub project: Option<String>,
/// RFC 3339 start timestamp.
pub started_at: String,
/// RFC 3339 end timestamp (None while running).
#[serde(default)]
pub ended_at: Option<String>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub log: Option<String>,
#[serde(default)]
pub args: Vec<String>,
/// Variable names only — never values.
#[serde(default)]
pub env_keys: Vec<String>,
/// running | stopped | failed | interrupted
#[serde(default = "status_running")]
pub status: String,
}
fn kind_agent() -> String {
"agent".to_string()
}
fn status_running() -> String {
"running".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionsFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub sessions: BTreeMap<String, SessionRecord>,
}
/// Path of sessions.json (inside the state directory).
pub fn file_path(app: &App) -> PathBuf {
app.events_dir().join("sessions.json")
}
pub fn load(app: &App) -> Result<SessionsFile> {
let p = file_path(app);
if !p.exists() {
return Ok(SessionsFile {
version: SESSIONS_VERSION,
sessions: BTreeMap::new(),
});
}
let text = std::fs::read_to_string(&p)
.with_context(|| format!("cannot read sessions file {}", p.display()))?;
let mut sf: SessionsFile = serde_json::from_str(&text).map_err(|e| {
anyhow!(
"sessions file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
p.display()
)
})?;
if sf.version < SESSIONS_VERSION {
sf.version = SESSIONS_VERSION;
save(app, &sf)?;
}
Ok(sf)
}
pub fn save(app: &App, sf: &SessionsFile) -> Result<()> {
let p = file_path(app);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = p.with_extension("json.tmp");
let text = serde_json::to_string_pretty(sf)?;
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &p)
.with_context(|| format!("cannot write sessions file {}", p.display()))?;
Ok(())
}
/// Open a new agent session; returns its id.
#[allow(clippy::too_many_arguments)]
pub fn start_agent(
app: &App,
agent: &str,
pid: u32,
cwd: Option<String>,
project: Option<String>,
args: Vec<String>,
env_keys: Vec<String>,
log: Option<String>,
) -> Result<String> {
let mut sf = load(app)?;
let id = crate::repl::session_id();
sf.sessions.insert(
id.clone(),
SessionRecord {
id: id.clone(),
kind: "agent".to_string(),
agent: Some(agent.to_string()),
pid: Some(pid),
cwd,
project,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
exit_code: None,
log,
args,
env_keys,
status: "running".to_string(),
},
);
save(app, &sf)?;
let _ = app.state.bump_sessions();
Ok(id)
}
/// Open a REPL session.
pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
let mut sf = load(app)?;
sf.sessions.insert(
id.to_string(),
SessionRecord {
id: id.to_string(),
kind: "repl".to_string(),
agent: None,
pid: None,
cwd,
project: None,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
exit_code: None,
log: None,
args: Vec::new(),
env_keys: Vec::new(),
status: "running".to_string(),
},
);
save(app, &sf)?;
let _ = app.state.bump_sessions();
Ok(())
}
/// Close the running session of an agent: the one matching the pid when
/// given, otherwise the most recent one.
pub fn finish_agent(app: &App, agent: &str, pid: Option<u32>, exit_code: i32, ok: bool) -> Result<()> {
let mut sf = load(app)?;
let mut candidates: Vec<String> = sf
.sessions
.iter()
.filter(|(_, r)| r.kind == "agent" && r.status == "running" && r.agent.as_deref() == Some(agent))
.map(|(k, _)| k.clone())
.collect();
candidates.sort_by(|a, b| sf.sessions[b].started_at.cmp(&sf.sessions[a].started_at));
let key = if let Some(p) = pid {
candidates
.iter()
.find(|k| sf.sessions[*k].pid == Some(p))
.cloned()
.or_else(|| candidates.first().cloned())
} else {
candidates.first().cloned()
};
if let Some(k) = key {
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)?;
}
Ok(())
}
/// Close a REPL session.
pub fn finish_repl(app: &App, id: &str) -> Result<()> {
let mut sf = load(app)?;
if let Some(r) = sf.sessions.get_mut(id) {
r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(0);
r.status = "stopped".to_string();
save(app, &sf)?;
}
Ok(())
}
/// Mark running sessions whose process is dead as interrupted. Returns the
/// number of sessions reconciled. REPL sessions (no pid) are left untouched:
/// the current REPL has no pid, and only 'am sessions' triggers this.
pub fn reconcile(app: &App) -> Result<usize> {
let mut sf = load(app)?;
let now = crate::installers::now_rfc3339();
let mut changed = 0;
for r in sf.sessions.values_mut() {
if r.status != "running" {
continue;
}
let dead = match r.pid {
Some(p) => !crate::process::is_running(p),
None => false,
};
if dead {
r.status = "interrupted".to_string();
r.ended_at = Some(now.clone());
changed += 1;
}
}
if changed > 0 {
save(app, &sf)?;
}
Ok(changed)
}
/// Duration of a session in seconds (elapsed when still running).
pub fn duration_s(r: &SessionRecord) -> Option<u64> {
let start = chrono::DateTime::parse_from_rfc3339(&r.started_at).ok()?;
let end = match &r.ended_at {
Some(e) => chrono::DateTime::parse_from_rfc3339(e).ok()?,
None => chrono::Utc::now().into(),
};
let secs = end
.with_timezone(&chrono::Utc)
.signed_duration_since(start.with_timezone(&chrono::Utc))
.num_seconds();
Some(secs.max(0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
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 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.path().join("agents");
p.bin_dir = dir.path().join("agents").join("bin");
p.log_dir = dir.path().join("logs");
p.state_file = dir.path().join("state.json");
p.probe_cache_file = dir.path().join("probe-cache.json");
p.config_dir = Some(dir.path().join("config"));
app.paths = p;
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
let _ = tag;
app
}
#[test]
fn agent_session_lifecycle() {
let app = test_app("sess");
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], None).unwrap();
let sf = load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "running");
assert_eq!(r.pid, Some(4242));
assert_eq!(app.state.load().unwrap().sessions_count, 1);
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let sf = load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "stopped");
assert_eq!(r.exit_code, Some(0));
assert!(r.ended_at.is_some());
assert!(duration_s(r).is_some());
}
#[test]
fn reconcile_marks_dead_pid_interrupted() {
let app = test_app("recon");
let id = start_agent(&app, "aider", u32::MAX - 1, None, None, vec![], vec![], None).unwrap();
let changed = reconcile(&app).unwrap();
assert_eq!(changed, 1);
let sf = load(&app).unwrap();
assert_eq!(sf.sessions.get(&id).unwrap().status, "interrupted");
}
#[test]
fn repl_session_lifecycle() {
let app = test_app("repl");
start_repl(&app, "s1", None).unwrap();
finish_repl(&app, "s1").unwrap();
let sf = load(&app).unwrap();
assert_eq!(sf.sessions.get("s1").unwrap().status, "stopped");
}
#[test]
fn finish_matches_by_pid_first() {
let app = test_app("multi");
start_agent(&app, "jcode", 100, None, None, vec![], vec![], None).unwrap();
start_agent(&app, "jcode", 200, None, None, vec![], vec![], None).unwrap();
finish_agent(&app, "jcode", Some(200), 1, false).unwrap();
let sf = load(&app).unwrap();
let stopped: Vec<_> = sf
.sessions
.values()
.filter(|r| r.status == "stopped" || r.status == "failed")
.collect();
assert_eq!(stopped.len(), 1);
assert_eq!(stopped[0].pid, Some(200));
assert_eq!(stopped[0].exit_code, Some(1));
assert_eq!(stopped[0].status, "failed");
}
}
+296
View File
@@ -0,0 +1,296 @@
//! System-shell gateway.
//!
//! The interactive prompt doubles as a gateway between the user and their
//! system shell: every input that is not an am command is executed by the
//! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...).
//!
//! The default shell is resolved at startup from, in order: the
//! `settings.default_shell` configuration entry, the `SHELL` environment
//! variable, `COMSPEC` (Windows), then the first shell found on PATH among
//! the platform's preferred shells. `shell <name>` switches at runtime.
use crate::config::Config;
use anyhow::{anyhow, Result};
use std::ffi::OsString;
use std::path::Path;
use std::process::Command as ProcessCommand;
/// One supported system shell and how to invoke it with a command line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellSpec {
/// Canonical short name ("pwsh", "cmd", "bash", ...).
pub name: &'static str,
/// Human-readable title ("PowerShell 7", "Command Prompt", ...).
pub title: &'static str,
/// Executable to spawn.
pub program: &'static str,
/// Arguments placed before the user's command line.
pub args: &'static [&'static str],
/// Executable probed on PATH to decide whether the shell is available.
pub probe: &'static str,
}
/// Every shell the gateway understands, in display order.
pub static KNOWN_SHELLS: &[ShellSpec] = &[
ShellSpec {
name: "pwsh",
title: "PowerShell 7",
program: "pwsh",
args: &["-NoLogo", "-NoProfile", "-Command"],
probe: "pwsh",
},
ShellSpec {
name: "powershell",
title: "Windows PowerShell",
program: "powershell",
args: &["-NoLogo", "-NoProfile", "-Command"],
probe: "powershell",
},
ShellSpec {
name: "cmd",
title: "Command Prompt",
program: "cmd",
args: &["/C"],
probe: "cmd",
},
ShellSpec {
name: "bash",
title: "Bash",
program: "bash",
args: &["-c"],
probe: "bash",
},
ShellSpec {
name: "zsh",
title: "Zsh",
program: "zsh",
args: &["-c"],
probe: "zsh",
},
ShellSpec {
name: "fish",
title: "Fish",
program: "fish",
args: &["-c"],
probe: "fish",
},
ShellSpec {
name: "sh",
title: "POSIX sh",
program: "sh",
args: &["-c"],
probe: "sh",
},
ShellSpec {
name: "nu",
title: "Nushell",
program: "nu",
args: &["-c"],
probe: "nu",
},
ShellSpec {
name: "elvish",
title: "Elvish",
program: "elvish",
args: &["-c"],
probe: "elvish",
},
];
/// Resolve a shell by canonical name or common alias, case-insensitively
/// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...).
pub fn find(name: &str) -> Option<&'static ShellSpec> {
let mut n = name.trim().to_ascii_lowercase();
if let Some(stripped) = n.strip_suffix(".exe") {
n = stripped.to_string();
}
KNOWN_SHELLS.iter().find(|s| {
s.name == n
|| match s.name {
"pwsh" => n == "powershell7" || n == "ps7",
"powershell" => n == "ps",
"cmd" => n == "dos" || n == "command" || n == "command-prompt",
"sh" => n == "posix" || n == "dash",
"nu" => n == "nushell",
_ => false,
}
})
}
/// Whether a shell executable can be found (or, for cmd on Windows, whether
/// COMSPEC is set).
pub fn probe_available(spec: &ShellSpec) -> bool {
which::which(spec.probe).is_ok()
|| (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some())
}
/// Resolve the user's default shell from the configuration, environment
/// variables, then the shells found on PATH. Always returns a shell.
pub fn resolve_default(
configured: Option<&str>,
shell_env: Option<OsString>,
comspec: Option<OsString>,
) -> &'static ShellSpec {
if let Some(s) = configured.and_then(find) {
return s;
}
if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) {
return s;
}
if let Some(s) = comspec.and_then(|v| from_env_path(&v)) {
return s;
}
let preferred: &[&str] = if cfg!(windows) {
&["pwsh", "powershell", "cmd"]
} else {
&["bash", "zsh", "fish", "sh"]
};
for name in preferred {
if let Some(s) = find(name) {
if probe_available(s) {
return s;
}
}
}
// Last resort: the platform's built-in shell.
find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist")
}
/// Extract a shell name from an environment value such as SHELL or COMSPEC.
fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> {
let text = value.to_string_lossy();
let name = Path::new(text.as_ref())
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
find(&name)
}
/// The gateway's runtime state: which shell executes system commands now,
/// which one was detected as the user's default, and which are installed.
#[derive(Debug, Clone)]
pub struct ShellSession {
/// Shell executing system commands right now.
pub current: &'static ShellSpec,
/// The shell detected as the user's default at startup.
pub default: &'static ShellSpec,
/// Shells found on this machine.
pub available: Vec<&'static ShellSpec>,
}
impl ShellSession {
/// Build a session: detect the default shell and probe availability.
pub fn start(config: &Config) -> Self {
let default = resolve_default(
config.settings.default_shell.as_deref(),
std::env::var_os("SHELL"),
std::env::var_os("COMSPEC"),
);
let available = KNOWN_SHELLS
.iter()
.filter(|s| probe_available(s))
.collect();
Self {
current: default,
default,
available,
}
}
/// Switch the active shell. Fails for unknown or unavailable shells.
pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> {
let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?;
if !self.available.iter().any(|s| s.name == spec.name) {
return Err(anyhow!(
"shell '{}' is not available on this machine (available: {})",
spec.name,
self.available
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join(", ")
));
}
self.current = spec;
Ok(spec)
}
/// Whether the active shell is the user's default shell.
pub fn is_default(&self) -> bool {
self.current.name == self.default.name
}
/// Execute one system command line in the current shell with inherited
/// stdio, so interactive programs keep working. Returns the exit code.
pub fn run(&self, line: &str) -> Result<i32> {
let status = ProcessCommand::new(self.current.program)
.args(self.current.args)
.arg(line)
.status()
.map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?;
Ok(status.code().unwrap_or(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_resolves_aliases() {
assert_eq!(find("pwsh").unwrap().name, "pwsh");
assert_eq!(find("PowerShell7").unwrap().name, "pwsh");
assert_eq!(find("ps").unwrap().name, "powershell");
assert_eq!(find("cmd.exe").unwrap().name, "cmd");
assert_eq!(find("CMD").unwrap().name, "cmd");
assert_eq!(find("nushell").unwrap().name, "nu");
assert_eq!(find("dash").unwrap().name, "sh");
assert!(find("blorp").is_none());
}
#[test]
fn resolve_default_prefers_configured_shell() {
let s = resolve_default(Some("zsh"), None, None);
assert_eq!(s.name, "zsh");
}
#[test]
fn resolve_default_uses_environment_values() {
// SHELL wins over COMSPEC when the configured value is absent.
let s = resolve_default(
None,
Some(OsString::from("/usr/bin/bash")),
Some(OsString::from("C:/Windows/system32/cmd.exe")),
);
assert_eq!(s.name, "bash");
// COMSPEC alone is honored.
let s = resolve_default(
None,
None,
Some(OsString::from("C:/Windows/system32/cmd.exe")),
);
assert_eq!(s.name, "cmd");
// Unrecognized values fall through to a probe — result is still a
// known shell.
let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None);
assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name));
}
#[test]
fn switch_validates_availability() {
let sh = find("sh").unwrap();
let bash = find("bash").unwrap();
let mut session = ShellSession {
current: sh,
default: sh,
available: vec![sh, bash],
};
assert_eq!(session.switch("bash").unwrap().name, "bash");
assert_eq!(session.current.name, "bash");
assert!(!session.is_default());
let err = session.switch("zsh").unwrap_err();
assert!(err.to_string().contains("not available"), "{err}");
let err = session.switch("blorp").unwrap_err();
assert!(err.to_string().contains("unknown shell"), "{err}");
}
}
+88 -2
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const STATE_VERSION: u32 = 1;
pub const STATE_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateFile {
@@ -14,6 +14,13 @@ pub struct StateFile {
pub version: u32,
#[serde(default)]
pub installed: BTreeMap<String, InstalledEntry>,
/// Total number of sessions started (incremented by the session
/// registry; additive since state v2).
#[serde(default)]
pub sessions_count: u64,
/// RFC 3339 timestamp of the last start/run of any agent.
#[serde(default)]
pub last_used: Option<String>,
}
impl Default for StateFile {
@@ -21,6 +28,8 @@ impl Default for StateFile {
Self {
version: STATE_VERSION,
installed: BTreeMap::new(),
sessions_count: 0,
last_used: None,
}
}
}
@@ -76,12 +85,18 @@ impl StateStore {
}
let text = std::fs::read_to_string(&self.path)
.with_context(|| format!("cannot read state file {}", self.path.display()))?;
let sf: StateFile = serde_json::from_str(&text).map_err(|e| {
let mut sf: StateFile = serde_json::from_str(&text).map_err(|e| {
anyhow!(
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
self.path.display()
)
})?;
// Additive migration: older files (v1) load unchanged and are
// rewritten as v2 on first contact.
if sf.version < STATE_VERSION {
sf.version = STATE_VERSION;
self.save(&sf)?;
}
Ok(sf)
}
@@ -130,6 +145,23 @@ impl StateStore {
}
Ok(())
}
/// Record the last use of an agent (start or run) — additive v2 data.
pub fn touch(&self, name: &str) -> Result<()> {
let mut sf = self.load()?;
if sf.installed.contains_key(name) {
sf.last_used = Some(crate::installers::now_rfc3339());
self.save(&sf)?;
}
Ok(())
}
/// Increment the total session counter (one per session start).
pub fn bump_sessions(&self) -> Result<()> {
let mut sf = self.load()?;
sf.sessions_count = sf.sessions_count.saturating_add(1);
self.save(&sf)
}
}
#[cfg(test)]
@@ -167,6 +199,60 @@ mod tests {
assert!(st.all().unwrap().is_empty());
}
#[test]
fn v1_file_is_migrated_additively() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
std::fs::write(
st.path(),
r#"{
"version": 1,
"installed": {
"claude-code": {
"name": "claude-code",
"method": "npm",
"install_dir": "/tmp/cc",
"bins": [],
"run": "claude",
"installed_at": "2026-01-01T00:00:00Z"
}
}
}"#,
)
.unwrap();
let sf = st.load().unwrap();
assert_eq!(sf.version, 2);
assert_eq!(sf.sessions_count, 0);
assert_eq!(sf.last_used, None);
assert!(sf.installed.contains_key("claude-code"));
// The migration is persisted on first contact.
let raw = std::fs::read_to_string(st.path()).unwrap();
assert!(raw.contains(r#""version": 2"#), "migrated file rewritten: {raw}");
}
#[test]
fn touch_records_last_used() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
st.touch("claude-code").unwrap(); // no entry: no-op
assert_eq!(st.load().unwrap().last_used, None);
let entry = InstalledEntry {
name: "claude-code".to_string(),
version: None,
method: "npm".to_string(),
install_dir: "/tmp/cc".to_string(),
bins: vec![],
run: "claude".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
st.set(&entry).unwrap();
st.touch("claude-code").unwrap();
assert!(st.load().unwrap().last_used.is_some());
}
#[test]
fn corrupt_file_is_reported() {
let dir = tempfile::tempdir().unwrap();
+459
View File
@@ -0,0 +1,459 @@
//! Generic Nushell-style tables: render a boxed table, filter rows with
//! where-like predicates and select a column with get.
use anyhow::{bail, Result};
use colored::Colorize;
/// One cell: the text to display plus a typed value for filtering
/// (number, byte size, or plain string).
#[derive(Debug, Clone)]
pub struct Cell {
pub text: String,
pub num: Option<f64>,
pub bytes: Option<u64>,
}
impl Cell {
pub fn str(s: impl Into<String>) -> Self {
Cell {
text: s.into(),
num: None,
bytes: None,
}
}
pub fn num(n: f64, text: String) -> Self {
Cell {
text,
num: Some(n),
bytes: None,
}
}
pub fn bytes(b: u64, text: String) -> Self {
Cell {
text,
num: None,
bytes: Some(b),
}
}
}
/// A tabular result produced by ls, ps, where or get.
#[derive(Debug, Clone, Default)]
pub struct DataTable {
pub columns: Vec<String>,
pub rows: Vec<Vec<Cell>>,
}
impl DataTable {
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns
.iter()
.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.
pub fn render(&self, color: bool, max_width: usize) -> String {
self.render_with(None, color, max_width)
}
/// Render with the colors of a theme (borders dimmed, header row in the
/// theme's header style instead of plain bold).
pub fn render_themed(
&self,
theme: &crate::theme::Theme,
color: bool,
max_width: usize,
) -> String {
self.render_with(Some(theme), color, max_width)
}
fn render_with(
&self,
theme: Option<&crate::theme::Theme>,
color: bool,
max_width: usize,
) -> String {
let max_width = max_width.max(20);
let headers = self.columns.clone();
if headers.is_empty() {
return String::new();
}
// Column 0 is the row index; the data columns follow.
let index_width = self.rows.len().to_string().len().max(1);
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
let mut cells: Vec<Vec<String>> = Vec::new();
for (i, row) in self.rows.iter().enumerate() {
let mut line = vec![i.to_string()];
for cell in row {
line.push(cell.text.clone());
}
for (c, cell) in line.iter().enumerate().skip(1) {
natural[c - 1] = natural[c - 1].max(visible_len(cell));
}
cells.push(line);
}
let line_width = |ncols: usize, widths: &[usize]| -> usize {
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
};
// Choose the final column set and widths.
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;
break;
}
data_cols -= 1;
}
let all_headers: Vec<String> = std::iter::once("#".to_string())
.chain(headers.iter().take(data_cols).cloned())
.collect();
let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned());
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
for (i, w) in all_widths.iter().enumerate() {
if i > 0 {
line.push(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push(right);
line
};
let bar = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.to_string(),
}
};
let row_line = |row: &[String], header: bool| -> String {
let mut line = bar("│");
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
line.push(' ');
let cell = fit_cell(c, all_widths[i]);
let rendered = if header {
match theme {
Some(t) if color => t.hdr(&cell),
_ => cell.bold().to_string(),
}
} else {
cell
};
line.push_str(&rendered);
line.push(' ');
line.push_str(&bar("│"));
}
line
};
let edge = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.dimmed().to_string(),
}
};
let mut out = String::new();
out.push_str(&edge(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row_line(&all_headers, true));
out.push('\n');
out.push_str(&edge(&border('├', '┼', '┤')));
out.push('\n');
for line in &cells {
out.push_str(&row_line(line, false));
out.push('\n');
}
out.push_str(&edge(&border('╰', '┴', '╯')));
out.push('\n');
out
}
}
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
/// become numbers, everything else is a string.
#[derive(Debug, Clone, PartialEq)]
enum WhereValue {
Num(f64),
Bytes(u64),
Str(String),
}
fn parse_value(raw: &str) -> WhereValue {
let lower = raw.trim().to_lowercase();
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
if let Some(n) = lower.strip_suffix(suffix) {
if let Ok(v) = n.trim().parse::<f64>() {
return WhereValue::Bytes((v * mult as f64) as u64);
}
}
}
if let Ok(v) = raw.trim().parse::<f64>() {
return WhereValue::Num(v);
}
WhereValue::Str(raw.trim().to_string())
}
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
match op {
">" => a > b,
"<" => a < b,
">=" => a >= b,
"<=" => a <= b,
"==" => (a - b).abs() < f64::EPSILON,
"!=" => (a - b).abs() >= f64::EPSILON,
_ => false,
}
}
/// Filter the rows of a table: where <field> <op> <value>.
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
let val = parse_value(value);
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
if !supported {
bail!("unknown operator '{op}' — use > < >= <= == != =~");
}
let mut out = table.clone();
out.rows.retain(|row| match row.get(idx) {
None => false,
Some(cell) => match &val {
WhereValue::Num(n) => match cell.num {
Some(v) => num_cmp(v, *n, op),
None => false,
},
WhereValue::Bytes(b) => match cell.bytes {
Some(v) => num_cmp(v as f64, *b as f64, op),
None => false,
},
WhereValue::Str(s) => {
let text = strip_ansi(&cell.text);
match op {
"==" => text.eq_ignore_ascii_case(s),
"!=" => !text.eq_ignore_ascii_case(s),
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
_ => false,
}
}
},
});
Ok(out)
}
/// Select one column: get <column>.
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
Ok(DataTable {
columns: vec![field.to_string()],
rows: table
.rows
.iter()
.map(|row| vec![row[idx].clone()])
.collect(),
})
}
/// Pad a cell to its column width, or truncate it (with an ellipsis,
/// preserving any leading color codes) when it is too wide.
fn fit_cell(s: &str, w: usize) -> String {
let n = visible_len(s);
if n <= w {
return format!("{s}{}", " ".repeat(w - n));
}
let plain = strip_ansi(s);
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
cut.push('…');
if s.contains('') {
let mut prefix = String::new();
let mut chars = s.chars().peekable();
while chars.peek() == Some(&'') {
for ch in chars.by_ref() {
prefix.push(ch);
if ch == 'm' {
break;
}
}
}
format!("{prefix}{cut}")
} else {
cut
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> DataTable {
DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![
vec![
Cell::str("src"),
Cell::str("dir"),
Cell::bytes(2048, "2.0 KB".into()),
],
vec![
Cell::str("am.exe"),
Cell::str("file"),
Cell::bytes(5_000_000, "4.8 MB".into()),
],
vec![
Cell::str("readme.md"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
],
],
}
}
#[test]
fn filters_numeric_and_size() {
let t = sample();
let big = filter_where(&t, "size", ">", "1mb").unwrap();
assert_eq!(big.rows.len(), 1);
assert_eq!(big.rows[0][0].text, "am.exe");
let eq = filter_where(&t, "type", "==", "dir").unwrap();
assert_eq!(eq.rows.len(), 1);
let contains = filter_where(&t, "name", "=~", "read").unwrap();
assert_eq!(contains.rows.len(), 1);
}
#[test]
fn get_selects_column() {
let t = sample();
let names = get_column(&t, "name").unwrap();
assert_eq!(names.columns, vec!["name".to_string()]);
assert_eq!(names.rows.len(), 3);
assert_eq!(names.rows[1][0].text, "am.exe");
}
#[test]
fn unknown_column_errors() {
let t = sample();
assert!(filter_where(&t, "nope", "==", "x").is_err());
assert!(get_column(&t, "nope").is_err());
}
#[test]
fn parses_where_values() {
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
}
#[test]
fn render_is_aligned() {
let t = sample();
let text = t.render(false, 200);
assert!(text.contains("╭"));
assert!(text.contains("name"));
assert!(text.contains("am.exe"));
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
}
#[test]
fn render_fits_narrow_width() {
// A table whose first column is too long to fit naturally.
let t = DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![vec![
Cell::str("a-very-long-file-name-that-overflows.txt"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
]],
};
// Trailing columns are dropped when too narrow.
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.
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}");
}
#[test]
fn fit_cell_truncates_and_keeps_color() {
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
let out = fit_cell(&colored, 8);
assert_eq!(strip_ansi(&out).chars().count(), 8);
assert!(out.starts_with("\u{1b}["));
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Output color themes: named palettes applied to help, tables, the
//! version output and the completion menu.
//!
//! A theme is a set of SGR (Select Graphic Rendition) parameter strings
//! (e.g. "1;38;5;75" = bold + 256-color 75). Painting wraps the text in
//! "\x1b[<sgr>m...\x1b[0m". The "mono" theme uses empty sequences and is
//! therefore plain text.
/// One color theme.
#[derive(Debug, Clone, Copy)]
pub struct Theme {
/// Machine name ("default", "ocean", ...).
pub name: &'static str,
/// Human-readable title.
pub title: &'static str,
/// Section headers ("Usage:", table header rows, ...) — bold.
pub header: &'static str,
/// Accent color: prompts, command names, status "running".
pub accent: &'static str,
/// Secondary color: types, values, status "installed".
pub info: &'static str,
/// Faint: search terms, secondary text, "not-installed".
pub dim: &'static str,
/// Frame color: the borders of tables and boxes (list, version, ls...).
pub border: &'static str,
/// Warnings and "external" status.
pub warn: &'static str,
/// Errors and "not-installable".
pub error: &'static str,
/// Success / "ok".
pub ok: &'static str,
/// Version table keys.
pub key: &'static str,
/// Version table values.
pub value: &'static str,
}
pub static THEMES: &[Theme] = &[
Theme {
name: "default",
title: "Default (green/cyan)",
header: "1;36",
accent: "1;32",
info: "36",
dim: "2",
border: "36",
warn: "33",
error: "31",
ok: "1;32",
key: "36",
value: "1",
},
Theme {
name: "ocean",
title: "Ocean (blues)",
header: "1;38;5;75",
accent: "1;38;5;81",
info: "38;5;117",
dim: "2",
border: "38;5;75",
warn: "38;5;221",
error: "38;5;203",
ok: "1;38;5;81",
key: "38;5;111",
value: "1",
},
Theme {
name: "sunset",
title: "Sunset (orange/pink)",
header: "1;38;5;209",
accent: "1;38;5;215",
info: "38;5;213",
dim: "2",
border: "38;5;209",
warn: "38;5;221",
error: "38;5;203",
ok: "1;38;5;215",
key: "38;5;213",
value: "1",
},
Theme {
name: "forest",
title: "Forest (greens)",
header: "1;38;5;71",
accent: "1;38;5;107",
info: "38;5;72",
dim: "2",
border: "38;5;71",
warn: "38;5;179",
error: "38;5;167",
ok: "1;38;5;107",
key: "38;5;108",
value: "1",
},
Theme {
name: "dracula",
title: "Dracula (purple/green)",
header: "1;38;5;141",
accent: "1;38;5;84",
info: "38;5;117",
dim: "2",
border: "38;5;141",
warn: "38;5;228",
error: "38;5;203",
ok: "1;38;5;84",
key: "38;5;141",
value: "1",
},
Theme {
name: "nord",
title: "Nord (arctic frost)",
header: "1;38;5;109",
accent: "1;38;5;108",
info: "38;5;110",
dim: "2",
border: "38;5;109",
warn: "38;5;222",
error: "38;5;167",
ok: "1;38;5;108",
key: "38;5;110",
value: "1",
},
Theme {
name: "solarized",
title: "Solarized (dark)",
header: "1;38;5;32",
accent: "1;38;5;100",
info: "38;5;37",
dim: "2",
border: "38;5;102",
warn: "38;5;136",
error: "38;5;160",
ok: "1;38;5;100",
key: "38;5;37",
value: "1",
},
Theme {
name: "gruvbox",
title: "Gruvbox (retro warm)",
header: "1;38;5;109",
accent: "1;38;5;142",
info: "38;5;66",
dim: "2",
border: "38;5;59",
warn: "38;5;214",
error: "38;5;167",
ok: "1;38;5;142",
key: "38;5;108",
value: "1",
},
Theme {
name: "catppuccin",
title: "Catppuccin (pastel)",
header: "1;38;5;111",
accent: "1;38;5;114",
info: "38;5;117",
dim: "2",
border: "38;5;111",
warn: "38;5;229",
error: "38;5;210",
ok: "1;38;5;114",
key: "38;5;140",
value: "1",
},
Theme {
name: "tokyo-night",
title: "Tokyo Night (neon)",
header: "1;38;5;111",
accent: "1;38;5;114",
info: "38;5;117",
dim: "2",
border: "38;5;111",
warn: "38;5;215",
error: "38;5;204",
ok: "1;38;5;114",
key: "38;5;141",
value: "1",
},
Theme {
name: "monokai",
title: "Monokai (classic IDE)",
header: "1;38;5;81",
accent: "1;38;5;148",
info: "38;5;81",
dim: "2",
border: "38;5;81",
warn: "38;5;185",
error: "38;5;197",
ok: "1;38;5;148",
key: "38;5;141",
value: "1",
},
Theme {
name: "matrix",
title: "Matrix (green phosphor)",
header: "1;38;5;46",
accent: "1;38;5;40",
info: "38;5;34",
dim: "2",
border: "38;5;34",
warn: "38;5;190",
error: "38;5;196",
ok: "1;38;5;40",
key: "38;5;40",
value: "1",
},
Theme {
name: "rose-pine",
title: "Rose Pine (elegant pink)",
header: "1;38;5;183",
accent: "1;38;5;216",
info: "38;5;117",
dim: "2",
border: "38;5;183",
warn: "38;5;180",
error: "38;5;204",
ok: "1;38;5;216",
key: "38;5;183",
value: "1",
},
Theme {
name: "amber",
title: "Amber (retro CRT)",
header: "1;38;5;214",
accent: "1;38;5;215",
info: "38;5;178",
dim: "2",
border: "38;5;172",
warn: "38;5;220",
error: "38;5;196",
ok: "1;38;5;215",
key: "38;5;214",
value: "1",
},
Theme {
name: "mono",
title: "Mono (no colors)",
header: "1",
accent: "",
info: "",
dim: "",
border: "",
warn: "",
error: "",
ok: "1",
key: "",
value: "",
},
];
/// The theme used when nothing else is configured.
pub fn default_theme() -> &'static Theme {
&THEMES[0]
}
/// Look a theme up by name (case-insensitive).
pub fn find(name: &str) -> Option<&'static Theme> {
let lower = name.trim().to_lowercase();
THEMES.iter().find(|t| t.name.to_lowercase() == lower)
}
/// Machine names of every theme, for error messages and help.
pub fn names() -> Vec<&'static str> {
THEMES.iter().map(|t| t.name).collect()
}
/// One line per theme: "name - title", for the help output.
pub fn describe_all() -> Vec<String> {
THEMES
.iter()
.map(|t| format!("{} - {}", t.name, t.title))
.collect()
}
impl Theme {
/// Wrap text in this SGR sequence (empty sequence = plain text).
pub fn paint(&self, sgr: &str, text: &str) -> String {
if sgr.is_empty() || text.is_empty() {
text.to_string()
} else {
format!("\x1b[{sgr}m{text}\x1b[0m")
}
}
pub fn hdr(&self, text: &str) -> String {
self.paint(self.header, text)
}
pub fn acc(&self, text: &str) -> String {
self.paint(self.accent, text)
}
pub fn inf(&self, text: &str) -> String {
self.paint(self.info, text)
}
pub fn dimmed(&self, text: &str) -> String {
self.paint(self.dim, text)
}
pub fn frame(&self, text: &str) -> String {
self.paint(self.border, text)
}
pub fn warn(&self, text: &str) -> String {
self.paint(self.warn, text)
}
pub fn err(&self, text: &str) -> String {
self.paint(self.error, text)
}
pub fn ok(&self, text: &str) -> String {
self.paint(self.ok, text)
}
pub fn key(&self, text: &str) -> String {
self.paint(self.key, text)
}
pub fn val(&self, text: &str) -> String {
self.paint(self.value, text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_themes_case_insensitively() {
assert_eq!(find("Ocean").unwrap().name, "ocean");
assert_eq!(find("default").unwrap().name, "default");
assert_eq!(find("DRACULA").unwrap().name, "dracula");
assert!(find("blorp").is_none());
}
#[test]
fn default_is_first() {
assert_eq!(default_theme().name, "default");
}
#[test]
fn mono_theme_paints_nothing() {
let mono = find("mono").unwrap();
// Bold (structure) survives; colors are gone.
assert_eq!(mono.hdr("x"), "x");
assert_eq!(mono.acc("x"), "x");
assert_eq!(mono.inf("x"), "x");
assert_eq!(mono.dimmed("x"), "x");
assert_eq!(mono.key("x"), "x");
assert_eq!(mono.val("x"), "x");
}
#[test]
fn paint_wraps_with_ansi() {
let t = default_theme();
let s = t.acc("ok");
assert!(s.starts_with("\x1b[1;32m"));
assert!(s.ends_with("\x1b[0m"));
}
#[test]
fn every_theme_has_a_description() {
for t in THEMES {
assert!(!t.title.is_empty(), "{}: missing title", t.name);
}
}
#[test]
fn theme_names_are_unique() {
let mut names: Vec<&str> = THEMES.iter().map(|t| t.name).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), THEMES.len(), "duplicate theme names");
}
#[test]
fn extended_catalog_is_findable() {
for name in [
"nord",
"solarized",
"gruvbox",
"catppuccin",
"tokyo-night",
"monokai",
"matrix",
"rose-pine",
"amber",
] {
assert!(find(name).is_some(), "missing theme {name}");
}
assert_eq!(THEMES.len(), 15);
}
#[test]
fn frame_paints_with_the_theme_border_color() {
let t = find("ocean").unwrap();
let s = t.frame("╭──╮");
assert!(s.starts_with(""));
assert_eq!(find("mono").unwrap().frame("x"), "x");
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Integration test for the event journal: parses the replayable
//! fixture shipped with the repository and exercises the append path.
use agent_manager::events::{self, Event, EventKind};
use std::path::PathBuf;
fn fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/events-sample.jsonl")
}
#[test]
fn fixture_parses_valid_lines_and_skips_corrupt_ones() {
let events = events::parse_file(&fixture());
assert_eq!(events.len(), 2, "the corrupt line must be skipped");
assert_eq!(events[0].kind, EventKind::Start);
assert_eq!(events[0].agent.as_deref(), Some("claude-code"));
assert_eq!(events[0].pid, Some(1234));
assert_eq!(events[0].env_keys, vec!["OPENAI_KEY".to_string()]);
assert_eq!(events[1].kind, EventKind::Stop);
assert_eq!(events[1].duration_s, Some(1364));
assert_eq!(events[1].exit_code, Some(0));
assert_eq!(events[1].reason.as_deref(), Some("signal"));
}
#[test]
fn append_then_read_back_through_read_events() {
let dir = tempfile::tempdir().unwrap();
let mut log = events::EventLog::new(dir.path());
log.append(
&Event::now(EventKind::Run)
.with_agent("jcode")
.with_cwd(Some("/tmp/demo".to_string()))
.with_exit_code(0),
)
.unwrap();
let all: Vec<Event> = events::read_events(dir.path(), 10);
assert_eq!(all.len(), 1);
assert_eq!(all[0].agent.as_deref(), Some("jcode"));
assert_eq!(all[0].cwd.as_deref(), Some("/tmp/demo"));
assert_eq!(all[0].exit_code, Some(0));
}
+3
View File
@@ -0,0 +1,3 @@
{"ts":"2026-08-15T14:39:26Z","kind":"start","agent":"claude-code","pid":1234,"cwd":"/home/bruno/proj/api","session":"20260815_143926_a1b2c3","env_keys":["OPENAI_KEY"]}
cette ligne est corrompue et doit etre ignoree par parse_file
{"ts":"2026-08-15T15:02:10Z","kind":"stop","agent":"claude-code","pid":1234,"reason":"signal","duration_s":1364,"exit_code":0}
+122
View File
@@ -0,0 +1,122 @@
//! Structured history: record, read, filter, migration, command.
mod common;
use agent_manager::commands::history_cmd;
use agent_manager::history::{self, HistoryEntry};
use std::path::PathBuf;
#[test]
fn cli_parses_history() {
use agent_manager::cli::{Cli, Command};
use clap::Parser;
let cli = Cli::parse_from(["am", "history", "--failed", "--search", "install"]);
match &cli.command {
Some(Command::History {
failed, search, ..
}) => {
assert!(*failed);
assert_eq!(search.as_deref(), Some("install"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn record_and_read_most_recent_first() {
let app = common::test_app("hist", &["history"]);
history::record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
history::record(
&app,
"s1",
&HistoryEntry::now("ls")
.with_kind("shell".to_string())
.with_duration_ms(12),
)
.unwrap();
let all = history::read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "ls");
assert_eq!(all[0].kind, "shell");
assert_eq!(all[0].duration_ms, Some(12));
assert_eq!(all[1].cmd, "install jcode");
}
#[test]
fn migration_imports_flat_history_once() {
let app = common::test_app("mig", &["history"]);
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
start group:dev
").unwrap();
assert_eq!(history::migrate(&app).unwrap(), 2);
assert_eq!(history::migrate(&app).unwrap(), 0, "no-op on second run");
let all = history::read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "start group:dev");
}
#[test]
fn filters_by_kind_search_and_failed() {
let app = common::test_app("filt", &["history"]);
history::record(
&app,
"s1",
&HistoryEntry::now("install jcode")
.with_kind("am".to_string())
.with_exit_code(Some(1)),
)
.unwrap();
history::record(
&app,
"s1",
&HistoryEntry::now("ls -la")
.with_kind("shell".to_string())
.with_cwd(Some("/tmp".to_string())),
)
.unwrap();
assert_eq!(history_cmd::filter(&app, Some("shell"), None, None, false, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, None, Some("install"), false, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, None, None, true, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, Some("/tmp"), None, false, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, None, Some("zzz"), false, None, 10).len(), 0);
}
#[test]
fn command_runs_and_migrates() {
let app = common::test_app("cmd", &["history"]);
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();
assert_eq!(code, 0);
// Migration created the structured file.
assert!(history::dir(&app).join("migrated.jsonl").exists());
}
#[test]
fn rerun_executes_shell_entry_with_yes() {
let app = common::test_app("rerun", &["history", "--rerun", "1", "--yes"]);
history::record(
&app,
"s1",
&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();
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());
// Zéro : erreur claire.
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0)).is_err());
}
#[test]
fn fixture_session_file_parses() {
// Guard for session_of_file used by the display layer.
assert_eq!(
history::session_of_file(&PathBuf::from("20260815_143926.jsonl")),
Some("20260815_143926".to_string())
);
}
+46
View File
@@ -0,0 +1,46 @@
//! init + context detection.
mod common;
use agent_manager::commands::init_cmd;
use agent_manager::context;
#[test]
fn context_detects_stack_from_markers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let stack = context::detect_stack(dir.path());
assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]);
assert_eq!(context::recommended_agents(&stack), vec!["smelt", "claude-code"]);
}
#[test]
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();
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());
// Avec --force, il écrase.
assert!(init_cmd::generate(dir.path(), true).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 text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("claude-code"), "default agent: {text}");
}
#[test]
fn command_runs_dry() {
let app = common::test_app("initdry", &["init", "--dry-run"]);
let code = init_cmd::run(&app, false).unwrap();
assert_eq!(code, 0);
}
+130
View File
@@ -0,0 +1,130 @@
//! Instrumentation: every real action appends one event to the journal;
//! dry runs write nothing. Uses the same isolated-App pattern as the other
//! integration tests (no user config or state leaks in).
mod common;
use agent_manager::app::App;
use agent_manager::cli::{Cli, StartArgs};
use agent_manager::commands::run_cmd;
use agent_manager::events::{self, Event, EventKind};
use agent_manager::state::InstalledEntry;
use clap::Parser;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Build an isolated App whose catalog contains the given agent definition.
fn app_with_agent(tag: &str, args: &[&str], agent_yaml: &str) -> App {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("am-instr-{tag}-{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = common::write_config(&dir, agent_yaml);
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
full.extend(args.iter().map(|s| s.to_string()));
let cli = Cli::parse_from(full);
let mut app = App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
app
}
fn events(app: &App) -> Vec<Event> {
events::read_events(&app.events_dir(), 0)
}
const ECHO_AGENT: &str = r#"
agents:
- name: echo-agent
install: { type: binary, url: "https://example.com/echo.zip" }
run: sh
args: ["-c", "echo hi"]
"#;
#[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();
assert_eq!(code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
assert_eq!(evs[0].kind, EventKind::Run);
assert_eq!(evs[0].agent.as_deref(), Some("echo-agent"));
assert_eq!(evs[0].exit_code, Some(0));
assert!(evs[0].duration_s.is_some());
}
#[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();
assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty());
}
fn sleeper_agent() -> String {
let (run, args) = if cfg!(windows) {
("ping", r#"["-n", "60", "127.0.0.1"]"#)
} else {
("sleep", r#"["60"]"#)
};
format!(
r#"
agents:
- name: sleeper
install: {{ type: binary, url: "https://example.com/sleeper.zip" }}
run: {run}
args: {args}
"#
)
}
#[test]
fn start_and_stop_emit_a_matching_pair() {
let app = app_with_agent("startstop", &["start", "sleeper", "--background"], &sleeper_agent());
// The agent must be "managed" for the PID to be recorded and stoppable.
let entry = InstalledEntry {
name: "sleeper".to_string(),
version: None,
method: "binary".to_string(),
install_dir: app.paths.install_dir.display().to_string(),
bins: vec![],
run: "sleeper".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let opts = StartArgs {
agent: Some("sleeper".to_string()),
background: true,
..Default::default()
};
let code = run_cmd::start(&app, &opts).unwrap();
assert_eq!(code, 0);
let stop_code = run_cmd::stop(&app, "sleeper", false, None).unwrap();
assert_eq!(stop_code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 2, "expected Start + Stop, got {:?}", evs);
assert_eq!(evs[0].kind, EventKind::Start);
assert_eq!(evs[0].agent.as_deref(), Some("sleeper"));
assert!(evs[0].pid.is_some());
assert!(evs[0].session.is_some(), "start events carry a session id");
assert_eq!(evs[1].kind, EventKind::Stop);
assert_eq!(evs[1].pid, evs[0].pid);
assert_eq!(evs[1].exit_code, Some(0));
assert!(evs[1].duration_s.is_some(), "stop events carry the duration");
}
#[test]
fn fixture_path_is_reachable() {
// Guard: the shared fixture used by events_test stays parsable here too.
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/events-sample.jsonl");
assert_eq!(events::parse_file(&p).len(), 2);
}
+2
View File
@@ -31,6 +31,7 @@ fn spawn_detect_stop_cycle() {
std::path::Path::new(prog),
&args,
&env,
None,
)
.unwrap();
assert!(pid > 0);
@@ -57,6 +58,7 @@ fn spawning_a_failing_program_reports_an_error() {
std::path::Path::new(prog),
&args,
&env,
None,
)
.unwrap_err();
assert!(
+92
View File
@@ -0,0 +1,92 @@
//! projects: aggregation from the journal, profiles from the config.
mod common;
use agent_manager::commands::projects_cmd;
use agent_manager::projects;
#[test]
fn aggregates_projects_from_fixture_events() {
let app = common::test_app("proj", &["projects"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
std::fs::create_dir_all(app.events_dir()).unwrap();
std::fs::write(
&path,
[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#,
r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#,
]
.join("
"),
)
.unwrap();
let out = projects::aggregate(&app);
assert_eq!(out.len(), 2);
let api = out.iter().find(|s| s.root == "/home/x/api").unwrap();
assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]);
assert_eq!(api.dominant.as_deref(), Some("claude-code"));
assert_eq!(api.sessions, 3);
assert_eq!(api.launches, 3);
assert_eq!(api.total_duration_s, 35);
assert!(projects::cache_file(&app).exists());
// La commande tourne (table + detail).
assert_eq!(projects_cmd::run(&app, None).unwrap(), 0);
assert_eq!(projects_cmd::run(&app, Some("api")).unwrap(), 0);
assert!(projects_cmd::run(&app, Some("inconnu")).is_err());
}
#[test]
fn profile_config_parses_and_resolves() {
let dir = std::env::temp_dir().join(format!("am-prof-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let root = dir.join("api");
std::fs::create_dir_all(&root).unwrap();
let cfg = common::write_config(
&dir,
&format!(
r#"
projects:
api:
root: '{}'
default_agent: claude-code
env:
FOO: bar
"#,
root.display()
),
);
use agent_manager::cli::Cli;
use clap::Parser;
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "projects"]);
let mut app = agent_manager::app::App::from_cli(cli).unwrap();
common::isolate(&mut app, &dir);
assert_eq!(app.config.projects.len(), 1);
let sub = root.join("src");
std::fs::create_dir_all(&sub).unwrap();
let (name, profile) = projects::profile_for(&app, &sub).unwrap();
assert_eq!(name, "api");
assert_eq!(profile.default_agent.as_deref(), Some("claude-code"));
assert_eq!(
projects::default_agent_for_dir(&app, &sub).as_deref(),
Some("claude-code")
);
// Hors du projet : pas de profil.
assert!(projects::default_agent_for_dir(&app, &std::env::temp_dir()).is_none());
// am start contextuel (issue #30) : résolution pure sans changer de cwd.
use agent_manager::commands::run_cmd;
assert_eq!(
run_cmd::resolve_start_target(&app, &sub, None).unwrap(),
"claude-code"
);
assert_eq!(
run_cmd::resolve_start_target(&app, &sub, Some("aider")).unwrap(),
"aider"
);
assert!(run_cmd::resolve_start_target(&app, &std::env::temp_dir(), None).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+115
View File
@@ -0,0 +1,115 @@
//! Quick wins: logs, list filters, config set, alias, open, history dedup.
mod common;
use agent_manager::commands::{alias_cmd, config_cmd, logs_cmd, open_cmd};
use agent_manager::state::InstalledEntry;
use std::path::PathBuf;
fn installed(app: &agent_manager::app::App, name: &str, dir: &PathBuf) {
let entry = InstalledEntry {
name: name.to_string(),
version: Some("1.0.0".to_string()),
method: "binary".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: name.to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
}
#[test]
fn list_sorts_and_filters_running() {
use agent_manager::commands::list_cmd;
let app = common::test_app("listqw", &["list", "--all"]);
let dir = app.paths.install_dir.join("x");
installed(&app, "jcode", &dir);
let code = list_cmd::run(&app, true, None, true, None).unwrap();
assert_eq!(code, 0, "--running sur un parc arrete donne une liste vide");
let code = list_cmd::run(&app, true, None, false, Some("version")).unwrap();
assert_eq!(code, 0);
assert!(list_cmd::run(&app, true, None, false, Some("bogus")).is_err());
}
#[test]
fn logs_tail_reads_the_agent_log() {
let app = common::test_app("logs", &["logs", "jcode"]);
let dir = app.paths.log_dir.clone();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("jcode.log"), "ligne 1
ligne 2
").unwrap();
let code = logs_cmd::run(&app, "jcode", 20, false).unwrap();
assert_eq!(code, 0);
assert_eq!(logs_cmd::log_path(&app, "jcode").unwrap(), dir.join("jcode.log"));
assert!(logs_cmd::run(&app, "inconnu", 20, false).is_err());
}
#[test]
fn config_set_writes_and_validates() {
let app = common::test_app("cfgset", &["config", "set", "settings.default_shell", "pwsh"]);
let code = config_cmd::set_value(&app, "settings.default_shell", "pwsh").unwrap();
assert_eq!(code, 0);
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
let text = std::fs::read_to_string(&cfg_path).unwrap();
assert!(text.contains("pwsh"), "cle ecrite: {text}");
// Une cle inconnue du schema est rejetee a la validation.
assert!(config_cmd::set_value(&app, "settings.cle_inconnue", "x").is_err());
}
#[test]
fn alias_add_list_remove() {
let app = common::test_app("alias", &["alias", "add", "cc", "claude-code"]);
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Add {
name: "cc".to_string(),
target: "claude-code".to_string(),
})
.unwrap();
assert_eq!(code, 0);
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
let text = std::fs::read_to_string(&cfg_path).unwrap();
assert!(text.contains("cc: claude-code"), "alias ecrit: {text}");
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::List).unwrap();
assert_eq!(code, 0);
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Remove {
name: "cc".to_string(),
})
.unwrap();
assert_eq!(code, 0);
// Cible inconnue refuse.
assert!(alias_cmd::run(
&app,
&agent_manager::cli::AliasCmd::Add {
name: "zz".to_string(),
target: "agent-inconnu".to_string(),
}
)
.is_err());
}
#[test]
fn open_resolves_install_dir() {
let app = common::test_app("open", &["open", "jcode"]);
let dir = app.paths.install_dir.join("jcode");
std::fs::create_dir_all(&dir).unwrap();
installed(&app, "jcode", &dir);
let target = open_cmd::resolve_target(&app, "jcode").unwrap();
assert_eq!(target, dir);
assert!(open_cmd::resolve_target(&app, "inconnu").is_err());
}
#[test]
fn history_migration_dedups_consecutive() {
use agent_manager::history;
let app = common::test_app("dedup", &["history"]);
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
install jcode
list
").unwrap();
assert_eq!(history::migrate(&app).unwrap(), 2, "doublons consecutifs sautes");
}
+90
View File
@@ -0,0 +1,90 @@
//! sessions command and session registry: lifecycle, counter, reconciliation
//! and CLI parsing.
mod common;
use agent_manager::cli::{Cli, Command};
use agent_manager::commands::sessions_cmd;
use agent_manager::sessions;
use clap::Parser;
#[test]
fn cli_parses_sessions() {
let cli = Cli::parse_from(["am", "sessions", "--status", "failed", "--show", "abc"]);
match &cli.command {
Some(Command::Sessions {
agent,
status,
show,
..
}) => {
assert!(agent.is_none());
assert_eq!(status.as_deref(), Some("failed"));
assert_eq!(show.as_deref(), Some("abc"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn agent_session_roundtrip_and_counter() {
let app = common::test_app("sess", &["sessions"]);
let id = sessions::start_agent(
&app,
"claude-code",
4242,
Some("/tmp".to_string()),
None,
vec![],
vec![],
None,
)
.unwrap();
let sf = sessions::load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "running");
assert_eq!(r.agent.as_deref(), Some("claude-code"));
assert_eq!(app.state.load().unwrap().sessions_count, 1);
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let sf = sessions::load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "stopped");
assert_eq!(r.exit_code, Some(0));
assert!(r.ended_at.is_some());
}
#[test]
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();
assert_eq!(code, 0);
// --json path returns the row set without error.
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
}
#[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());
sessions::start_repl(&app, "r1", None).unwrap();
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1")).is_err());
}
#[test]
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();
assert_eq!(code, 0, "dry-run resume");
}
#[test]
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();
assert_eq!(code, 0);
}
+110
View File
@@ -0,0 +1,110 @@
//! stats + log: aggregation and filtering over the event journal.
mod common;
use agent_manager::app::App;
use agent_manager::commands::log_cmd;
use agent_manager::commands::stats_cmd;
use agent_manager::events;
fn app_with_events(tag: &str, lines: &[&str]) -> App {
let app = common::test_app(tag, &["stats"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
let mut text = String::new();
for l in lines {
text.push_str(l);
text.push('\n');
}
std::fs::create_dir_all(app.events_dir()).unwrap();
std::fs::write(&path, text).unwrap();
app
}
#[test]
fn stats_aggregate_counts_everything() {
let app = app_with_events(
"agg",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
r#"{"ts":"2026-08-03T12:00:00Z","kind":"update","agent":"a"}"#,
],
);
let rows = stats_cmd::aggregate(&app, None, None).unwrap();
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.launches, 2);
assert_eq!(a.total_duration_s, 40);
assert_eq!(a.avg_duration_s, 20);
assert_eq!(a.failures, 0);
assert_eq!(a.installs, 1);
assert_eq!(a.updates, 1);
let b = rows.iter().find(|r| r.agent == "b").unwrap();
assert_eq!(b.failures, 1);
assert_eq!(b.launches, 1);
}
#[test]
fn stats_period_cuts_old_events() {
let app = app_with_events(
"period",
&[r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#],
);
assert!(stats_cmd::aggregate(&app, None, Some("30d")).unwrap().is_empty());
assert_eq!(stats_cmd::aggregate(&app, None, None).unwrap().len(), 1);
assert!(stats_cmd::aggregate(&app, None, Some("7d")).unwrap().is_empty());
}
#[test]
fn log_filters_by_agent_kind_and_limit() {
let app = app_with_events(
"logf",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-01T10:00:01Z","kind":"start","agent":"a","pid":42}"#,
r#"{"ts":"2026-08-01T10:00:02Z","kind":"run","agent":"b","exit_code":1}"#,
],
);
let all = log_cmd::filter_events(&app, None, None, None, 50).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].agent.as_deref(), Some("b"), "most recent first");
let only_a = log_cmd::filter_events(&app, Some("a"), None, None, 50).unwrap();
assert_eq!(only_a.len(), 2);
let starts = log_cmd::filter_events(&app, None, Some("start"), None, 50).unwrap();
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].pid, Some(42));
let limited = log_cmd::filter_events(&app, None, None, None, 1).unwrap();
assert_eq!(limited.len(), 1);
let since = log_cmd::filter_events(&app, None, None, Some("2026-09-01"), 50).unwrap();
assert!(since.is_empty());
}
#[test]
fn stats_command_runs() {
let app = app_with_events(
"cmd",
&[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#],
);
let code = stats_cmd::run(&app, None, None).unwrap();
assert_eq!(code, 0);
}
#[test]
fn journal_reads_back_through_events_api() {
let app = app_with_events("api", &[]);
let mut log = events::EventLog::new(app.events_dir());
log.append(
&events::Event::now(events::EventKind::Run)
.with_agent("x")
.with_exit_code(0),
)
.unwrap();
let all = events::read_events(&app.events_dir(), 0);
assert_eq!(all.len(), 1);
}
+97
View File
@@ -0,0 +1,97 @@
//! top, report and log --follow (issues #37 #38).
mod common;
use agent_manager::commands::stats_cmd;
use agent_manager::events;
fn app_with_events(tag: &str, lines: &[&str]) -> agent_manager::app::App {
let app = common::test_app(tag, &["stats"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
std::fs::create_dir_all(app.events_dir()).unwrap();
let mut text = String::new();
for l in lines {
text.push_str(l);
text.push('\n');
}
std::fs::write(&path, text).unwrap();
app
}
#[test]
fn top_sorts_by_launches() {
let app = app_with_events(
"top",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":0}"#,
],
);
let rows = stats_cmd::top(&app, None).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].agent, "a");
assert_eq!(rows[0].launches, 2);
}
#[test]
fn report_md_contains_numbers_and_table() {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let lines = [
format!(r#"{{"ts":"{today}T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}}"#),
format!(r#"{{"ts":"{today}T10:01:00Z","kind":"run","agent":"a","exit_code":1,"duration_s":5}}"#),
format!(r#"{{"ts":"{today}T10:02:00Z","kind":"install","agent":"b"}}"#),
];
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let app = app_with_events("report", &refs);
let md = stats_cmd::report_md(&app, 7);
assert!(md.contains("# Rapport agent-manager"), "md: {md}");
assert!(md.contains("Lancements : 2"));
assert!(md.contains("Échecs : 1"));
assert!(md.contains("Changements"));
assert!(md.contains("| a | 2 |"), "table top: {md}");
}
#[test]
fn report_command_writes_file() {
let app = app_with_events("reportcmd", &[]);
let out = app.events_dir().join("report-test.md");
let code = stats_cmd::run_report(&app, true, false, Some(&out)).unwrap();
assert_eq!(code, 0);
assert!(out.exists());
let _ = std::fs::remove_file(&out);
}
#[test]
fn text_after_is_utf8_safe() {
use agent_manager::commands::log_cmd;
let text = "héllo wörld
second line
";
assert_eq!(log_cmd::text_after(text, 0), text);
assert_eq!(log_cmd::text_after(text, 3), "llo wörld
second line
");
let full = text.len();
assert_eq!(log_cmd::text_after(text, full), "");
}
#[test]
fn follow_flag_parses() {
use agent_manager::cli::{Cli, Command};
use clap::Parser;
let cli = Cli::parse_from(["am", "log", "--follow"]);
match &cli.command {
Some(Command::Log { follow, .. }) => assert!(*follow),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn journal_still_reads_after_events_api() {
let app = app_with_events("api", &[]);
let mut log = events::EventLog::new(app.events_dir());
log.append(&events::Event::now(events::EventKind::Run).with_agent("x")).unwrap();
assert_eq!(events::read_events(&app.events_dir(), 0).len(), 1);
}