Compare commits

...
15 Commits
Author SHA1 Message Date
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
28 changed files with 3982 additions and 241 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.12"
version = "0.2.14"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.2.12"
version = "0.2.14"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+393
View File
@@ -0,0 +1,393 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.0** · Statut : propositions, non engagées
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
---
## 📖 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]
+34 -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,15 @@ 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);
}
/// 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 +135,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 {
+17
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>,
}
@@ -131,6 +138,16 @@ pub enum Command {
/// Agent name or alias
agent: String,
},
/// 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),
+16 -7
View File
@@ -2,7 +2,7 @@
use super::*;
use crate::doctor::{self, Status};
use crate::output::{print_json, status_style, Table};
use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
use anyhow::Result;
@@ -27,16 +27,25 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
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)
}
+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.
+14 -5
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;
@@ -50,18 +50,27 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
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"
+4
View File
@@ -4,6 +4,7 @@ pub mod completion_cmd;
pub mod config_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod help_cmd;
pub mod info_cmd;
pub mod install_cmd;
pub mod list_cmd;
@@ -14,6 +15,7 @@ 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;
@@ -55,6 +57,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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::Info { agent } => info_cmd::run(app, agent),
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),
+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(", ")));
+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");
}
+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 {
+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();
+404
View File
@@ -0,0 +1,404 @@
//! 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,
}
/// 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: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn with_project(mut self, project: impl Into<String>) -> Self {
self.project = Some(project.into());
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-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.
/// Retries briefly when the file is locked (Windows).
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
let month = month_of(&event.ts);
let path = self.file_for(&month);
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(path);
}
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())
))
}
}
#[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");
}
}
+1392
View File
File diff suppressed because it is too large Load Diff
+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);
}
+14
View File
@@ -19,6 +19,8 @@ pub mod config;
pub mod deps;
pub mod doctor;
pub mod download;
pub mod events;
pub mod help;
pub mod installers;
pub mod nav;
pub mod output;
@@ -30,6 +32,7 @@ pub mod runner;
pub mod shell;
pub mod state;
pub mod tables;
pub mod theme;
pub mod toolchain;
pub mod version;
@@ -37,6 +40,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) {
+234 -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(),
}
}
@@ -307,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"));
}
}
+509 -120
View File
@@ -13,14 +13,14 @@
//! available.
use crate::app::App;
use crate::cli::{Cli, Command, StartArgs};
use crate::cli::{Command, StartArgs};
use crate::commands;
use crate::nav;
use crate::ps;
use crate::tables::DataTable;
use crate::shell::ShellSession;
use crate::theme::Theme;
use anyhow::{anyhow, Result};
use clap::CommandFactory;
use colored::Colorize;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
@@ -50,6 +50,9 @@ pub struct AmCompleter {
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
/// Active color theme (used by the boxed menu highlight; follows the
/// app theme when it is switched with the REPL 'theme <name>' command).
theme: std::cell::Cell<&'static crate::theme::Theme>,
}
/// One-line descriptions for the Tab completion menu.
@@ -73,6 +76,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("export", "backup config + state"),
("import", "restore a backup"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("ls", "list files and directories (Nushell-style)"),
("dir", "list files and directories (same as ls)"),
("cd", "change directory — Tab completes folders"),
@@ -86,7 +90,15 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
impl AmCompleter {
pub fn new(app: &App) -> Self {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
let completer =
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups);
completer.theme.set(app.theme());
completer
}
/// Point the menu at a new theme (after 'theme <name>' in the REPL).
pub fn set_theme(&self, theme: &'static crate::theme::Theme) {
self.theme.set(theme);
}
pub fn from_catalog(
@@ -121,11 +133,12 @@ impl AmCompleter {
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self {
theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "ls", "dir",
"cd", "ps", "where", "get", "help", "version", "exit",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
@@ -172,6 +185,12 @@ impl AmCompleter {
"shell" => {
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
}
"theme" => {
pool = crate::theme::names()
.iter()
.map(|s| s.to_string())
.collect();
}
"cd" | "chdir" => {
// Complete directories only — dive one level per Tab.
let cwd = current_dir();
@@ -239,25 +258,82 @@ impl Completer for AmCompleter {
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = cands
.into_iter()
.map(|c| {
// Nushell-style menu: show a description next to the
// candidate when one is known.
let display = match self.descriptions.get(&c) {
Some(d) if !d.is_empty() => format!("{c}\t{d}"),
_ => c.clone(),
};
Pair {
display,
let pairs = if cands.len() < 2 {
// A single match is inserted immediately (no menu).
cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
}
})
.collect();
})
.collect()
} else {
self.boxed_pairs(&cands)
};
Ok((start, pairs))
}
}
impl AmCompleter {
/// Build the boxed Tab menu: one candidate per box line, so rustyline's
/// single-column layout renders the whole menu as one white-framed
/// table. Decorative lines (borders, header) replace the typed word
/// with the common prefix, so completion behaves exactly as before.
fn boxed_pairs(&self, cands: &[String]) -> Vec<Pair> {
let rows: Vec<(String, String)> = cands
.iter()
.map(|c| (c.clone(), self.descriptions.get(c).cloned().unwrap_or_default()))
.collect();
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
let lines = crate::output::completion_box(&rows);
let lcp = common_prefix(cands);
let n = cands.len();
let data_start = if has_desc { 3 } else { 1 };
// Pad every line to the terminal width: rustyline then lays the
// menu out in one column, one box line per row.
let width = crate::output::terminal_width()
.unwrap_or(120)
.max(lines.iter().map(|l| l.chars().count()).max().unwrap_or(0));
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let replacement = if i >= data_start && i < data_start + n {
cands[i - data_start].clone()
} else {
lcp.clone()
};
let display = format!(
"{line}{}",
" ".repeat(width.saturating_sub(line.chars().count()))
);
Pair {
display,
replacement,
}
})
.collect()
}
}
/// Longest common prefix of the candidates (chars, not bytes).
fn common_prefix(items: &[String]) -> String {
let mut iter = items.iter();
let Some(first) = iter.next() else {
return String::new();
};
let mut prefix = first.clone();
for s in iter {
while !s.starts_with(&prefix) && !prefix.is_empty() {
prefix.pop();
}
if prefix.is_empty() {
break;
}
}
prefix
}
impl Hinter for AmCompleter {
type Hint = String;
@@ -266,7 +342,42 @@ impl Hinter for AmCompleter {
}
}
impl Highlighter for AmCompleter {}
impl Highlighter for AmCompleter {
/// Style the boxed completion menu: borders dim, the header row in the
/// theme header style, candidate names in the accent color.
fn highlight_candidate<'c>(
&self,
candidate: &'c str,
completion: rustyline::CompletionType,
) -> std::borrow::Cow<'c, str> {
use std::borrow::Cow;
let _ = completion;
match candidate.chars().next() {
Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.get().frame(candidate)),
Some('│') => {
let cells: Vec<&str> = candidate.split('│').collect();
if cells.len() >= 4 {
if cells[1].trim() == "name" && cells[2].trim() == "description" {
Cow::Owned(self.theme.get().hdr(candidate))
} else {
let bar = self.theme.get().frame("│");
Cow::Owned(format!(
"{}{}{}{}{}",
bar,
self.theme.get().acc(cells[1]),
bar,
cells[2],
bar
))
}
} else {
Cow::Borrowed(candidate)
}
}
_ => Cow::Borrowed(candidate),
}
}
}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
@@ -349,7 +460,8 @@ pub fn banner_art() -> Vec<String> {
}
/// The boxed banner: block art, Shell Gateway section (default shell
/// highlighted), command groups and the stats footer.
/// highlighted), the active color theme, command groups and the stats
/// footer. Every color comes from the active theme.
pub fn banner_box(
version: &str,
agents: usize,
@@ -358,34 +470,67 @@ pub fn banner_box(
session: &ShellSession,
session_id: &str,
cwd: &str,
theme: &Theme,
color: bool,
) -> String {
let w = BOX_WIDTH;
let mut rows: Vec<String> = Vec::new();
let inner = |content: String| format!("│ {} │", pad_visible(content, w));
let frame = |s: &str| -> String {
if color {
theme.frame(s)
} else {
s.to_string()
}
};
let inner = |content: String| {
format!(
"{} {} {}",
frame("│"),
pad_visible(content, w),
frame("│")
)
};
let blank = || inner(" ".to_string());
// Top border with the centered header.
let header = format!(" Agent Manager v{version} · shell gateway ");
let fill = w + 2 - visible_len(&header);
// Top border with the centered header (title in the theme header color,
// border in the theme frame color).
let title = format!(" Agent Manager v{version} · shell gateway ");
let title = if color {
theme.hdr(&title)
} else {
title
};
let fill = w + 2 - visible_len(&title);
let left = fill / 2;
let right = fill - left;
rows.push(dim(
&format!("╭{}{}{}╮", "─".repeat(left), header, "─".repeat(right)),
color,
// Paint each segment separately: the title's own reset must not cancel
// the frame color of the dashes on its right.
rows.push(format!(
"{}{}{}",
frame(&format!("╭{}", "─".repeat(left))),
title,
frame(&format!("{}╮", "─".repeat(right))),
));
// Block-art title.
// Block-art title, painted in the theme accent color.
for art in banner_art() {
rows.push(inner(art));
rows.push(inner(if color {
theme.acc(&art)
} else {
art
}));
}
// Shell gateway: the user's default shell is highlighted at startup.
rows.push(blank());
rows.push(inner(bold_cyan("Shell Gateway", color)));
rows.push(inner(if color {
theme.hdr("Shell Gateway")
} else {
"Shell Gateway".to_string()
}));
let current = format!(
"◉ {} — {}{}",
green(session.current.name, color),
green(session.current.name, theme, color),
session.current.title,
if session.is_default() { " (default)" } else { "" }
);
@@ -401,15 +546,55 @@ pub fn banner_box(
} else {
others.join(" · ")
};
rows.push(inner(dim(&format!(" ⇄ {avail}"), color)));
rows.push(inner(dim(&format!(" ⇄ {avail}"), theme, color)));
rows.push(inner(dim(
" system commands run in the active shell — 'shell <name>' switches",
theme,
color,
)));
// Active color theme: the current one highlighted, every other theme
// listed, plus the two ways to switch.
rows.push(blank());
rows.push(inner(if color {
theme.hdr("Theme")
} else {
"Theme".to_string()
}));
let current_theme = format!(
"◉ {} — {}",
green(theme.name, theme, color),
theme.title
);
rows.push(inner(format!(" {current_theme}")));
let theme_others: Vec<&str> = crate::theme::THEMES
.iter()
.map(|t| t.name)
.filter(|n| *n != theme.name)
.collect();
// The full list may not fit one line: wrap it into two ⇄ lines.
let half = theme_others.len().div_ceil(2);
for (i, chunk) in theme_others.chunks(half).enumerate() {
let line = format!(
"{}⇄ {}",
if i == 0 { " " } else { " " },
chunk.join(" · ")
);
rows.push(inner(dim(&line, theme, color)));
}
rows.push(inner(dim(
" 'am --theme <name>' to switch · settings.theme in the config",
theme,
color,
)));
// am command groups.
rows.push(blank());
rows.push(inner(bold_cyan("Commands", color)));
rows.push(inner(if color {
theme.hdr("Commands")
} else {
"Commands".to_string()
}));
rows.push(inner(" catalog list · status · search · info".to_string()));
rows.push(inner(
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
@@ -419,16 +604,18 @@ pub fn banner_box(
.to_string(),
));
rows.push(inner(
" shell ls · cd · ps · where · get · shell · help · version · exit".to_string(),
" shell ls · cd · ps · where · get · shell · theme · help · version · exit".to_string(),
));
// Stats footer.
rows.push(blank());
rows.push(inner(dim(
&format!(
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · /help for commands",
session.current.name
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · /help for commands",
session.current.name,
theme.name
),
theme,
color,
)));
let prefix = format!(" session {session_id} · cwd ");
@@ -436,9 +623,10 @@ pub fn banner_box(
rows.push(inner(format!("{prefix}{}", shorten_head(cwd, budget))));
rows.push(inner(yellow(
" ✦ Tip: 'shell <name>' switches the system shell · '!' forces a system command",
theme,
color,
)));
rows.push(dim(&format!("╰{}╯", "─".repeat(w + 2)), color));
rows.push(frame(&format!("╰{}╯", "─".repeat(w + 2))));
rows.join("\n")
}
@@ -446,6 +634,7 @@ pub fn banner_box(
/// The full startup banner (box + welcome + tip).
pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
let color = app.color();
let theme = app.theme();
let mut text = banner_box(
env!("CARGO_PKG_VERSION"),
agent_count(app),
@@ -454,6 +643,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
session,
session_id,
&current_dir_string(),
theme,
color,
);
text.push('\n');
@@ -462,38 +652,58 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
);
text.push_str(&format!(
"{} {}\n",
yellow("✦ Tip:", color),
yellow("✦ Tip:", theme, color),
dim(
&format!(
"system commands run in {} — 'shell <name>' switches · '!' forces · 'cd' works in this session",
session.current.name
),
theme,
color,
),
));
text
}
/// One-line status shown before every prompt (Hermes-style status bar).
/// The second parameter is the duration of the previous command (None before
/// Color an emoji glyph with the active theme (style names: green, blue,
/// yellow, cyan).
fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String {
if !color {
return glyph.to_string();
}
match style {
"green" => theme.acc(glyph),
"blue" => theme.inf(glyph),
"yellow" => theme.warn(glyph),
"cyan" => theme.inf(glyph),
_ => glyph.to_string(),
}
}
/// One-line status shown before every prompt, with colored emojis:
/// ⚙ am v0.2.13 │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
/// The last parameter is the duration of the previous command (None before
/// the first one).
fn print_status(app: &App, session: &ShellSession, started: Instant, last: Option<Duration>) {
fn print_status(app: &App, started: Instant, last: Option<Duration>) {
let color = app.color();
let sep = dim(" │ ", color);
let cwd = shorten_head(&current_dir_string(), 26);
let shell_part = format!(
let theme = app.theme();
let sep = dim(" │ ", theme, color);
let cwd = shorten_head(&current_dir_string(), 30);
let folder = format!(
"{} {}",
bold_cyan(session.current.name, color),
dim(&cwd, color)
emoji("🗂️", "cyan", theme, color),
dim(&cwd, theme, color)
);
let counts = dim(
&format!(
"{} agents · {} external · {} managed",
agent_count(app),
external_count(app),
installed_count(app)
),
color,
let counts = format!(
"{} {} {} {} {} {} {} {}",
dim(&agent_count(app).to_string(), theme, color),
emoji("🤖", "green", theme, color),
dim("·", theme, color),
dim(&external_count(app).to_string(), theme, color),
emoji("🌐", "blue", theme, color),
dim("·", theme, color),
dim(&installed_count(app).to_string(), theme, color),
emoji("📊", "yellow", theme, color),
);
let last_text = match last {
Some(d) if d.as_secs() >= 1 => format_elapsed(d),
@@ -501,11 +711,19 @@ fn print_status(app: &App, session: &ShellSession, started: Instant, last: Optio
Some(d) => format!("{}µs", d.as_micros()),
None => "—".to_string(),
};
let last_part = dim(&format!("⚡ {last_text}"), color);
let elapsed = dim(&format_elapsed(started.elapsed()), color);
let last_part = format!(
"{} {}",
emoji("⚡", "yellow", theme, color),
dim(&last_text, theme, color)
);
let elapsed = dim(
&format!("⏲ {}", format_elapsed(started.elapsed())),
theme,
color,
);
println!(
"{}am v{}{sep}{shell_part}{sep}{counts}{sep}{last_part}{sep}⏲ {elapsed}",
dim(" ⚙ ", color),
"{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
dim(" ⚙ ", theme, color),
env!("CARGO_PKG_VERSION"),
);
}
@@ -544,33 +762,25 @@ fn pad_visible(content: String, width: usize) -> String {
}
}
fn dim(s: &str, color: bool) -> String {
fn dim(s: &str, theme: &Theme, color: bool) -> String {
if color {
s.dimmed().to_string()
theme.dimmed(s)
} else {
s.to_string()
}
}
fn bold_cyan(s: &str, color: bool) -> String {
fn green(s: &str, theme: &Theme, color: bool) -> String {
if color {
s.cyan().bold().to_string()
theme.acc(s)
} else {
s.to_string()
}
}
fn green(s: &str, color: bool) -> String {
fn yellow(s: &str, theme: &Theme, color: bool) -> String {
if color {
s.green().bold().to_string()
} else {
s.to_string()
}
}
fn yellow(s: &str, color: bool) -> String {
if color {
s.yellow().to_string()
theme.warn(s)
} else {
s.to_string()
}
@@ -669,9 +879,7 @@ pub fn run(app: &App) -> Result<i32> {
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
if !std::io::stdin().is_terminal() && !forced {
// Piped stdin: print the help instead of hanging.
let mut cmd = Cli::command();
cmd.print_help()?;
println!();
crate::help::print_general(app);
return Ok(2);
}
let mut session = ShellSession::start(&app.config);
@@ -705,7 +913,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None;
loop {
print_status(app, session, started, last_duration);
print_status(app, started, last_duration);
match rl.readline("❯ ") {
Ok(line) => {
let trimmed = line.trim().to_string();
@@ -716,6 +924,11 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
let t = Instant::now();
let result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed());
// 'theme <name>' may have switched the app theme: repoint the
// completion menu at the new palette.
if let Some(h) = rl.helper_mut() {
h.set_theme(app.theme());
}
match result {
Ok(true) => break,
Ok(false) => {}
@@ -740,7 +953,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None;
loop {
print_status(app, session, started, last_duration);
print_status(app, started, last_duration);
eprint!("❯ ");
std::io::stderr().flush().ok();
let mut line = String::new();
@@ -773,11 +986,15 @@ fn handle_input(
match line {
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
"help" | "?" | "/help" => {
print_help();
crate::help::print_general(app);
return Ok(false);
}
"version" | "/version" => {
println!("am {}", env!("CARGO_PKG_VERSION"));
crate::help::print_version(app);
return Ok(false);
}
"theme" | "/theme" => {
theme_command(app, None);
return Ok(false);
}
_ => {}
@@ -790,6 +1007,15 @@ fn handle_input(
};
match cmd {
"shell" => shell_command(session, arg),
"theme" => theme_command(app, arg),
"help" => match arg {
Some(topic) => {
if let Err(e) = crate::help::print_command(app, topic) {
app.log.error(format!("{e:#}"));
}
}
None => crate::help::print_general(app),
},
_ => app
.log
.error(&format!("unknown command '/{cmd}' — type '/help'")),
@@ -826,6 +1052,40 @@ fn handle_line(
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
// Nushell-style help: 'ls -h', 'ls --help', 'start -h', ... show the
// colored command help. System commands keep their own -h handling.
if is_am_command(cmd) || is_shell_command(cmd) {
let help_flag_pos = rest.iter().position(|t| t == "-h" || t == "--help");
let dd_pos = rest.iter().position(|t| t == "--");
let help_requested = match help_flag_pos {
Some(p) => dd_pos.is_none_or(|d| p < d),
None => false,
};
// 'run <agent> --help' passes --help through to the agent; only
// 'run -h' right after the command name is a help request.
let passthrough = cmd == "run" && help_flag_pos != Some(0);
if help_requested && !passthrough {
if let Err(e) = crate::help::print_command(app, cmd) {
app.log.error(format!("{e:#}"));
}
return Ok(false);
}
}
// help <topic> | help --find <text> | help commands
if cmd == "help" {
return run_help_command(app, rest);
}
if cmd == "version" {
crate::help::print_version(app);
return Ok(false);
}
// theme | theme <name>: list the themes or switch the active one.
if cmd == "theme" {
theme_command(app, rest.first().map(|s| s.as_str()));
return Ok(false);
}
// Session directory change: kept in the am process, not a child shell.
if matches!(cmd, "cd" | "chdir") {
change_dir(rest)?;
@@ -986,6 +1246,14 @@ fn handle_line(
Ok(exit_after)
}
/// Whether a word is one of the REPL's shell-level commands (ls, ps, ...).
fn is_shell_command(word: &str) -> bool {
matches!(
word,
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "help" | "version" | "exit"
)
}
/// Whether a word is one of the am commands (everything else is executed by
/// the system shell).
fn is_am_command(word: &str) -> bool {
@@ -1014,7 +1282,14 @@ fn is_am_command(word: &str) -> bool {
/// Resolve the path argument of ls/dir (defaults to the current directory).
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
Ok(match rest.first() {
// A leading '--' ends flag parsing, so 'ls -- -h' can list a file
// literally named "-h" instead of showing the help.
let args: Vec<&String> = if rest.first().map(String::as_str) == Some("--") {
rest.iter().skip(1).collect()
} else {
rest.iter().collect()
};
Ok(match args.first() {
Some(p) => crate::config::expand_path(p),
None => current_dir(),
})
@@ -1024,7 +1299,10 @@ fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
/// The table is rendered to fit the terminal width so rows never wrap.
fn show_table(app: &App, last: &mut Option<DataTable>, table: DataTable) {
let width = crate::output::terminal_width().unwrap_or(120);
print!("{}", table.render(app.color(), width));
print!(
"{}",
table.render_themed(app.theme(), app.color(), width)
);
*last = Some(table);
}
@@ -1044,6 +1322,18 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
}
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
// Nushell-style: 'ls -h' inside a pipeline shows the command help.
if rest
.iter()
.take_while(|t| t.as_str() != "--")
.any(|t| t == "-h" || t == "--help")
&& matches!(cmd, "ls" | "dir" | "ps" | "where" | "get")
{
if let Err(e) = crate::help::print_command(app, cmd) {
app.log.error(format!("{e:#}"));
}
return Ok(false);
}
match cmd {
"ls" => {
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
@@ -1123,6 +1413,59 @@ fn shell_command(session: &mut ShellSession, arg: Option<&str>) {
}
}
/// 'theme' in the REPL: list the themes, or switch with 'theme <name>'.
fn theme_command(app: &App, arg: Option<&str>) {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => print_theme_status(app),
Some(name) => match crate::theme::find(name) {
Some(t) => {
app.set_theme(t);
let color = app.color();
println!(
"{} {}",
if color { t.hdr("theme:") } else { "theme:".to_string() },
format!("{} — {}", t.acc(t.name), t.title)
);
}
None => app.log.error(format!(
"unknown theme '{name}' — available: {}",
crate::theme::names().join(", ")
)),
},
}
}
/// List the available themes, highlighting the active one.
fn print_theme_status(app: &App) {
let theme = app.theme();
let color = app.color();
let hdr = |s: &str| -> String {
if color {
theme.hdr(s)
} else {
s.to_string()
}
};
println!("{}", hdr("Theme"));
println!(
" ◉ {} — {}",
theme.acc(theme.name),
theme.title
);
let others: Vec<&str> = crate::theme::THEMES
.iter()
.map(|t| t.name)
.filter(|n| *n != theme.name)
.collect();
println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · "))));
println!(
" {}",
theme.dimmed(
"'theme <name>' to switch · '--theme <name>' or settings.theme to change the default"
)
);
}
fn print_shell_status(session: &ShellSession) {
let others: Vec<&str> = session
.available
@@ -1144,44 +1487,24 @@ fn print_shell_status(session: &ShellSession) {
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
}
fn print_help() {
println!("commands:");
println!(" list [--all] list installed agents (--all: whole catalog)");
println!(" status [agent] show agent state");
println!(" search <keyword> search the catalog");
println!(" info <agent> show agent details");
println!(" install <agent> [--method <m>] install an agent (--force to reinstall)");
println!(" uninstall <agent> [--purge] remove an agent");
println!(" update <agent> | update --all update agents");
println!(" start <agent> [--background] start an agent");
println!(" stop <agent> [--force] stop an agent");
println!(" restart <agent> [--background] restart an agent");
println!(" run <agent> [args...] run an agent command directly");
println!(" doctor [--fix] check the environment");
println!(" config show | path | validate | edit");
println!(" completion <shell> generate a completion script");
println!(" self-update [--check] update agent-manager");
println!(" self-uninstall remove agent-manager and everything it created");
println!(" export [--output FILE] backup config + state");
println!(" import <file> restore a backup");
println!(" shell [list|<name>] show or switch the system shell");
println!(" ls [path] · dir [path] list files and directories (Nushell-style table)");
println!(" ps list processes");
println!(" where <col> <op> <val> filter the last table (ls | where size > 1mb)");
println!(" get <col> select a column (ls | get name)");
println!(" cd <dir> change directory — Tab completes folders");
println!(" help | version | exit this help, the version, quit");
println!();
println!("pipelines:");
println!(" ls | where size > 1mb | get name");
println!(" ps | where name =~ am");
println!();
println!("system gateway:");
println!(" any other input runs as a system command in the active shell");
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
println!(" Tab inserts the common prefix and lists candidates with descriptions");
println!();
println!("slash commands: /help · /version · /exit · /shell <name>");
/// 'help' in the REPL: the overview, a command/agent topic, the command
/// list, or a --find search — the same Nushell-style help as 'am help'.
fn run_help_command(app: &App, rest: &[String]) -> Result<bool> {
let mut iter = rest.iter();
match iter.next().map(|s| s.as_str()) {
None => crate::help::print_general(app),
Some("--find") => {
let text = iter.next().map(|s| s.as_str()).unwrap_or("");
crate::help::print_find(app, text);
}
Some("-h") | Some("--help") => {
crate::help::print_command(app, "help")?;
}
Some(topic) => {
crate::help::print_command(app, topic)?;
}
}
Ok(false)
}
#[cfg(test)]
@@ -1288,6 +1611,7 @@ mod tests {
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
crate::theme::default_theme(),
false,
);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
@@ -1301,6 +1625,71 @@ mod tests {
assert!(text.contains("12 external"));
assert!(text.contains("3 managed"));
assert!(text.contains("agent-manager"));
// The splash screen shows the active theme and every other theme.
assert!(text.contains("Theme"));
assert!(text.contains("◉ default"));
assert!(text.contains("theme default"));
assert!(text.contains("--theme <name>"));
for t in crate::theme::THEMES {
if t.name != "default" {
assert!(text.contains(t.name), "banner misses theme {}", t.name);
}
}
}
#[test]
fn banner_box_is_themed_and_still_aligned() {
let session = test_session();
let ocean = crate::theme::find("ocean").unwrap();
let text = banner_box(
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
ocean,
true,
);
// ANSI color codes are present with the ocean theme...
assert!(text.contains("\x1b["), "no ANSI in themed banner:\n{text}");
assert!(strip_ansi(&text).contains("◉ ocean"));
// ...and the visible box stays aligned (strip the ANSI codes).
let widths: Vec<usize> = text
.lines()
.map(|l| strip_ansi(l).chars().count())
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
}
#[test]
fn banner_top_border_right_dashes_keep_the_frame_color() {
let session = test_session();
let ocean = crate::theme::find("ocean").unwrap();
let text = banner_box(
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
ocean,
true,
);
let top = text.lines().next().unwrap();
// The painted title ends with a reset; the dashes after it must be
// re-painted with the frame color instead of staying default.
let Some(reset_pos) = top.find("\x1b[0m") else {
panic!("no title reset in top border: {top:?}");
};
let tail = &top[reset_pos + "\x1b[0m".len()..];
assert!(tail.contains('─'), "no dashes after the title: {tail:?}");
assert!(
tail.contains("\x1b["),
"right dashes not re-painted with the frame color: {tail:?}"
);
}
#[test]
+47 -13
View File
@@ -58,6 +58,26 @@ impl DataTable {
/// 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() {
@@ -133,42 +153,56 @@ impl DataTable {
line.push(right);
line
};
let row_line = |row: &[String], bold: bool| -> String {
let mut line = String::from("│");
let bar = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.to_string(),
}
};
let row_line = |row: &[String], header: bool| -> String {
let mut line = bar("│");
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
line.push(' ');
let cell = fit_cell(c, all_widths[i]);
let rendered = if bold {
cell.bold().to_string()
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
}
+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("/tmp/demo")
.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}