Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba901df953 | ||
|
|
e50e8d3e41 | ||
|
|
498af684c9 | ||
|
|
2282f71829 | ||
|
|
f968d7d0b0 | ||
|
|
b3437b0601 | ||
|
|
843c3e1278 | ||
|
|
4db567eebe | ||
|
|
2bbe45e508 | ||
|
|
4b0f02a7c4 | ||
|
|
79143e197d | ||
|
|
14f873bfdd | ||
|
|
53f3fa34e7 | ||
|
|
5841926b25 | ||
|
|
979875fa63 | ||
|
|
93c32b60ba | ||
|
|
0f4ba71219 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.10"
|
||||
version = "0.2.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.10"
|
||||
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
@@ -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.*
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(""", "\"")
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
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 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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
+74
-48
@@ -51,9 +51,8 @@ pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
Some(total)
|
||||
}
|
||||
|
||||
fn modified_str(path: &Path) -> String {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
fn modified_text(meta: &std::fs::Metadata) -> String {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<chrono::Local> = t.into();
|
||||
@@ -94,59 +93,86 @@ fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// One ls row: (sort key, cells). A single symlink_metadata call per entry
|
||||
/// (the modification time comes from the same metadata).
|
||||
fn row_for(p: &Path, color: bool) -> Option<(String, Vec<Cell>)> {
|
||||
let meta = std::fs::symlink_metadata(p).ok()?;
|
||||
let file_type = meta.file_type();
|
||||
let name = p.file_name().and_then(|n| n.to_str())?.to_string();
|
||||
let (kind, style) = if file_type.is_symlink() {
|
||||
("symlink", if p.exists() { 3 } else { 4 })
|
||||
} else if file_type.is_dir() {
|
||||
("dir", 1)
|
||||
} else if is_executable(p, &meta) {
|
||||
("file", 2)
|
||||
} else {
|
||||
("file", 0)
|
||||
};
|
||||
let (size_text, size_bytes) = if file_type.is_dir() {
|
||||
match dir_size(p) {
|
||||
Some(s) => (format_size(s), Some(s)),
|
||||
None => ("—".to_string(), None),
|
||||
}
|
||||
} else {
|
||||
(format_size(meta.len()), Some(meta.len()))
|
||||
};
|
||||
let mut display = name.clone();
|
||||
if file_type.is_dir() {
|
||||
display.push('/');
|
||||
} else if file_type.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(p) {
|
||||
display.push_str(&format!(" -> {}", target.display()));
|
||||
}
|
||||
}
|
||||
Some((
|
||||
name.to_lowercase(),
|
||||
vec![
|
||||
Cell::str(paint(&display, color, style)),
|
||||
Cell::str(kind.to_string()),
|
||||
Cell::bytes(size_bytes.unwrap_or(0), size_text),
|
||||
Cell::str(modified_text(&meta)),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the ls table (columns: name, type, size, modified). Directories
|
||||
/// come first, then alphabetical order.
|
||||
///
|
||||
/// Rows are built in parallel: recursive directory sizes dominate the cost,
|
||||
/// so a listing with many subdirectories stays fast (like Nushell, which
|
||||
/// also parallelizes its listing).
|
||||
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
|
||||
let rd = std::fs::read_dir(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut rows: Vec<(String, Vec<Cell>)> = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_type = meta.file_type();
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let (kind, style) = if file_type.is_symlink() {
|
||||
("symlink", if p.exists() { 3 } else { 4 })
|
||||
} else if file_type.is_dir() {
|
||||
("dir", 1)
|
||||
} else if is_executable(&p, &meta) {
|
||||
("file", 2)
|
||||
} else {
|
||||
("file", 0)
|
||||
};
|
||||
let (size_text, size_bytes) = if file_type.is_dir() {
|
||||
match dir_size(&p) {
|
||||
Some(s) => (format_size(s), Some(s)),
|
||||
None => ("—".to_string(), None),
|
||||
}
|
||||
} else {
|
||||
(format_size(meta.len()), Some(meta.len()))
|
||||
};
|
||||
let mut display = name.clone();
|
||||
if file_type.is_dir() {
|
||||
display.push('/');
|
||||
} else if file_type.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(&p) {
|
||||
display.push_str(&format!(" -> {}", target.display()));
|
||||
let entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(8);
|
||||
let chunk_size = entries.len().div_ceil(threads).max(1);
|
||||
let rows: Vec<(String, Vec<Cell>)> = std::thread::scope(|scope| {
|
||||
let mut handles = Vec::new();
|
||||
for chunk in entries.chunks(chunk_size) {
|
||||
let chunk: Vec<PathBuf> = chunk.to_vec();
|
||||
handles.push(scope.spawn(move || {
|
||||
chunk
|
||||
.into_iter()
|
||||
.filter_map(|p| row_for(&p, color))
|
||||
.collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for handle in handles {
|
||||
if let Ok(mut rows) = handle.join() {
|
||||
out.append(&mut rows);
|
||||
}
|
||||
}
|
||||
rows.push((
|
||||
name.to_lowercase(),
|
||||
vec![
|
||||
Cell::str(paint(&display, color, style)),
|
||||
Cell::str(kind.to_string()),
|
||||
Cell::bytes(size_bytes.unwrap_or(0), size_text),
|
||||
Cell::str(modified_str(&p)),
|
||||
],
|
||||
));
|
||||
}
|
||||
out
|
||||
});
|
||||
|
||||
// Directories first, then alphabetical.
|
||||
let mut rows = rows;
|
||||
rows.sort_by(|a, b| {
|
||||
let a_dir = a.1[1].text == "dir";
|
||||
let b_dir = b.1[1].text == "dir";
|
||||
|
||||
+234
-13
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+549
-118
@@ -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,42 +460,77 @@ 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,
|
||||
installed: usize,
|
||||
external: usize,
|
||||
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 { "" }
|
||||
);
|
||||
@@ -400,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(),
|
||||
@@ -418,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 · {installed} installed · 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 ");
|
||||
@@ -435,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")
|
||||
}
|
||||
@@ -445,13 +634,16 @@ 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),
|
||||
installed_count(app),
|
||||
external_count(app),
|
||||
session,
|
||||
session_id,
|
||||
¤t_dir_string(),
|
||||
theme,
|
||||
color,
|
||||
);
|
||||
text.push('\n');
|
||||
@@ -460,40 +652,78 @@ 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).
|
||||
fn print_status(app: &App, session: &ShellSession, started: Instant) {
|
||||
/// 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, started: Instant, last: Option<Duration>) {
|
||||
let color = app.color();
|
||||
let sep = dim(" │ ", color);
|
||||
let cwd = shorten_head(¤t_dir_string(), 26);
|
||||
let shell_part = format!(
|
||||
let theme = app.theme();
|
||||
let sep = dim(" │ ", theme, color);
|
||||
let cwd = shorten_head(¤t_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 · {} installed",
|
||||
agent_count(app),
|
||||
installed_count(app)
|
||||
),
|
||||
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),
|
||||
Some(d) if d.as_millis() >= 1 => format!("{}ms", d.as_millis()),
|
||||
Some(d) => format!("{}µs", d.as_micros()),
|
||||
None => "—".to_string(),
|
||||
};
|
||||
let last_part = format!(
|
||||
"{} {}",
|
||||
emoji("⚡", "yellow", theme, color),
|
||||
dim(&last_text, theme, color)
|
||||
);
|
||||
let elapsed = dim(
|
||||
&format!("⏲ {}", format_elapsed(started.elapsed())),
|
||||
theme,
|
||||
color,
|
||||
);
|
||||
let elapsed = dim(&format_elapsed(started.elapsed()), color);
|
||||
println!(
|
||||
"{}am v{}{sep}{shell_part}{sep}{counts}{sep}⏲ {elapsed}",
|
||||
dim(" ⚙ ", color),
|
||||
"{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
|
||||
dim(" ⚙ ", theme, color),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
}
|
||||
@@ -532,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()
|
||||
}
|
||||
@@ -609,6 +831,25 @@ fn installed_count(app: &App) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of external agents (installed outside agent-manager) detected on
|
||||
/// the PATH — the same definition used by the "external" status in am list.
|
||||
fn external_count(app: &App) -> usize {
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
let managed: std::collections::BTreeSet<String> = app
|
||||
.state
|
||||
.load()
|
||||
.ok()
|
||||
.map(|s| s.installed.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
app.catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter(|a| !a.hidden && a.installable)
|
||||
.filter(|a| !managed.contains(&a.name))
|
||||
.filter(|a| probe.path_of(&a.name).is_some())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn current_dir_string() -> String {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
@@ -638,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);
|
||||
@@ -672,8 +911,9 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
let mut last_duration: Option<Duration> = None;
|
||||
loop {
|
||||
print_status(app, session, started);
|
||||
print_status(app, started, last_duration);
|
||||
match rl.readline("❯ ") {
|
||||
Ok(line) => {
|
||||
let trimmed = line.trim().to_string();
|
||||
@@ -681,7 +921,15 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
continue;
|
||||
}
|
||||
let _ = rl.add_history_entry(trimmed.as_str());
|
||||
match handle_input(app, session, &mut last, &trimmed) {
|
||||
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) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
@@ -703,8 +951,9 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
let mut last_duration: Option<Duration> = None;
|
||||
loop {
|
||||
print_status(app, session, started);
|
||||
print_status(app, started, last_duration);
|
||||
eprint!("❯ ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
@@ -715,7 +964,10 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match handle_input(app, session, &mut last, &line) {
|
||||
let t = Instant::now();
|
||||
let result = handle_input(app, session, &mut last, &line);
|
||||
last_duration = Some(t.elapsed());
|
||||
match result {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
@@ -734,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);
|
||||
}
|
||||
_ => {}
|
||||
@@ -751,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'")),
|
||||
@@ -787,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)?;
|
||||
@@ -947,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 {
|
||||
@@ -975,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(),
|
||||
})
|
||||
@@ -985,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);
|
||||
}
|
||||
|
||||
@@ -1005,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())?);
|
||||
@@ -1084,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
|
||||
@@ -1105,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)]
|
||||
@@ -1245,9 +1607,11 @@ mod tests {
|
||||
"9.9.9",
|
||||
65,
|
||||
3,
|
||||
12,
|
||||
&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();
|
||||
@@ -1258,7 +1622,74 @@ mod tests {
|
||||
assert!(text.contains("(default)"));
|
||||
assert!(text.contains("bash"));
|
||||
assert!(text.contains("/help for commands"));
|
||||
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
@@ -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
@@ -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"), "[1mx[0m");
|
||||
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("[38;5;75m"));
|
||||
assert_eq!(find("mono").unwrap().frame("x"), "x");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Vendored
+3
@@ -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}
|
||||
Reference in New Issue
Block a user