Compare commits

..
33 Commits
Author SHA1 Message Date
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
53 changed files with 7241 additions and 321 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.9"
version = "0.2.14"
dependencies = [
"anyhow",
"chrono",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.2.9"
version = "0.3.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"
@@ -36,7 +36,7 @@ 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"] }
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"
+21 -1
View File
@@ -90,6 +90,9 @@ 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 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 |
@@ -103,13 +106,24 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| 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 log [agent] --kind --since | journal des événements : tout ce que am a fait |
| am sessions [agent] --status --show | registre des sessions (agents + shell interactif) |
| am history --search --failed | historique des commandes (am + shell), filtrable |
| 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 config show / edit / add / set | gère votre configuration |
| 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 self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
@@ -294,6 +308,12 @@ 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). Interrogeables avec am stats,
am log, am sessions et am history.
**Comment mettre à jour am ?** Relancez l'installeur one-liner (ou
am self-update si le dépôt de release est configuré).
+396
View File
@@ -0,0 +1,396 @@
# 🗺️ 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 phases 1 à 3 restent à planifier.
---
## 📖 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).
---
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
Chaque item peut être chiffré et planifié indépendamment ; les phases 1 à 3
seront détaillées dans des documents dédiés au moment de leur démarrage.*
+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}");
}
+4 -4
View File
@@ -341,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]
+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 {
+131
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,32 @@ 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,
},
/// Manage command aliases (cc -> claude-code)
#[command(subcommand)]
Alias(AliasCmd),
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -91,6 +123,42 @@ 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>,
},
/// 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>,
},
/// 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,
},
/// Install an agent and its dependencies
Install {
/// Agent name or alias
@@ -126,11 +194,48 @@ 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,
},
/// 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),
@@ -204,6 +309,25 @@ pub struct StartArgs {
pub notify: bool,
}
/// 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 {
@@ -220,4 +344,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()));
+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)
}
+96
View File
@@ -0,0 +1,96 @@
//! history: search and reuse the structured command history.
use super::*;
use crate::history::{self, HistoryEntry};
use crate::output::print_json;
/// 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,
) -> Result<i32> {
history::migrate(app)?;
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)
}
+1 -1
View File
@@ -154,7 +154,7 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
} else {
("not-installed", String::new())
};
let word = crate::output::status_style(status_word, color);
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.
+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)
}
+6
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,11 @@ 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)]),
);
app.log.success(&format!(
"installed {} (v{version}) in {}",
agent.title(),
+30 -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,25 +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", "AGENT", "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"
+120
View File
@@ -0,0 +1,120 @@
//! 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)
}
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(),
}
}
}
pub fn run(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<i32> {
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));
}
}
+61 -1
View File
@@ -1,19 +1,29 @@
//! 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 run_cmd;
pub mod search_cmd;
pub mod self_uninstall;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod version_cmd;
use crate::app::App;
use crate::cli::Command;
@@ -33,7 +43,19 @@ 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::Alias(sub) => alias_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -46,6 +68,25 @@ 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::Log {
agent,
kind,
since,
limit,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit),
Command::Sessions {
agent,
project,
status,
show,
} => sessions_cmd::run(
app,
agent.as_deref(),
project.as_deref(),
status.as_deref(),
show.as_deref(),
),
Command::Install {
agent,
method,
@@ -54,7 +95,26 @@ 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,
} => history_cmd::run(
app,
kind.as_deref(),
cwd.as_deref(),
search.as_deref(),
*failed,
session.as_deref(),
*limit,
),
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),
+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)
}
+86 -2
View File
@@ -2,7 +2,9 @@
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;
@@ -37,6 +39,7 @@ fn start_one(
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());
if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
if app.dry_run() {
@@ -51,7 +54,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,6 +96,7 @@ 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());
@@ -78,7 +105,19 @@ fn start_one(
.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_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,11 +162,29 @@ 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)?;
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)?;
if stopped {
app.log.success(&format!("{} stopped", agent.title()));
@@ -175,6 +232,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 +241,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)
}
+12 -4
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,17 +33,25 @@ 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(", ")));
+146
View File
@@ -0,0 +1,146 @@
//! 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>,
) -> Result<i32> {
if let Some(id) = show {
return show_one(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)
}
/// "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)
}
}
+212
View File
@@ -0,0 +1,212 @@
//! stats: usage statistics computed from the event journal.
use super::*;
use crate::events::{self, EventKind};
use crate::output::{print_json, Table};
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())
}
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)
}
/// 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");
}
+7
View File
@@ -5,6 +5,7 @@
//! 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;
@@ -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(),
@@ -178,6 +180,11 @@ fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
.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 {
+6
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,11 @@ 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)]),
);
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)
}
+4
View File
@@ -66,6 +66,10 @@ 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>,
}
impl Settings {
+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);
}
}
+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");
}
}
+1553
View File
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
//! 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)
}
/// 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())
);
}
}
+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,
+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);
}
+17
View File
@@ -16,9 +16,13 @@ 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 installers;
pub mod nav;
pub mod output;
@@ -27,9 +31,11 @@ pub mod ps;
pub mod process;
pub mod repl;
pub mod runner;
pub mod sessions;
pub mod shell;
pub mod state;
pub mod tables;
pub mod theme;
pub mod toolchain;
pub mod version;
@@ -37,6 +43,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) {
+77 -50
View File
@@ -51,9 +51,8 @@ pub fn dir_size(path: &Path) -> Option<u64> {
Some(total)
}
fn modified_str(path: &Path) -> String {
std::fs::metadata(path)
.and_then(|m| m.modified())
fn modified_text(meta: &std::fs::Metadata) -> String {
meta.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Local> = t.into();
@@ -94,59 +93,86 @@ fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
}
}
/// 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 mut rows: Vec<(String, Vec<Cell>)> = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
let meta = match std::fs::symlink_metadata(&p) {
Ok(m) => m,
Err(_) => continue,
};
let file_type = meta.file_type();
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
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()));
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);
}
}
rows.push((
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_str(&p)),
],
));
}
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";
@@ -163,9 +189,10 @@ pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
})
}
/// Render a Nushell-style boxed table of the entries of a path.
/// 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))
Ok(ls_table(path, color)?.render(color, 200))
}
/// Completion candidates for a path fragment typed after cd or ls.
+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"));
}
}
+669 -124
View File
File diff suppressed because it is too large Load Diff
+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");
}
}
+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();
+161 -30
View File
@@ -53,12 +53,39 @@ impl DataTable {
.position(|c| c.eq_ignore_ascii_case(name))
}
/// Render the boxed Nushell-style table.
pub fn render(&self, color: bool) -> String {
/// 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 widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
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()];
@@ -66,25 +93,54 @@ impl DataTable {
line.push(cell.text.clone());
}
for (c, cell) in line.iter().enumerate().skip(1) {
widths[c - 1] = widths[c - 1].max(visible_len(cell));
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().cloned())
.chain(headers.iter().take(data_cols).cloned())
.collect();
let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned());
for (c, w) in all_widths.iter_mut().enumerate() {
if c < all_headers.len() {
*w = (*w).max(visible_len(&all_headers[c]));
}
}
let pad = |s: &str, w: usize| -> String {
let n = visible_len(s);
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
};
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
@@ -97,42 +153,56 @@ impl DataTable {
line.push(right);
line
};
let row_line = |row: &[String], bold: bool| -> String {
let mut line = String::from("│");
for (i, c) in row.iter().enumerate() {
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 = pad(c, all_widths[i]);
let rendered = if bold {
cell.bold().to_string()
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('│');
line.push_str(&bar("│"));
}
line
};
let dim = |s: &str| -> String {
if color {
s.dimmed().to_string()
} else {
s.to_string()
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(&dim(&border('╭', '┬', '╮')));
out.push_str(&edge(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row_line(&all_headers, true));
out.push('\n');
out.push_str(&dim(&border('├', '┼', '┤')));
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(&dim(&border('╰', '┴', '╯')));
out.push_str(&edge(&border('╰', '┴', '╯')));
out.push('\n');
out
}
@@ -231,6 +301,33 @@ pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
})
}
/// 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();
@@ -318,11 +415,45 @@ mod tests {
#[test]
fn render_is_aligned() {
let t = sample();
let text = t.render(false);
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}
+105
View File
@@ -0,0 +1,105 @@
//! 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).unwrap();
assert_eq!(code, 0);
// Migration created the structured file.
assert!(history::dir(&app).join("migrated.jsonl").exists());
}
#[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: "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);
}
+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");
}
+73
View File
@@ -0,0 +1,73 @@
//! 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).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 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)).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);
}