Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4cd034b2 | ||
|
|
aeca797ae0 | ||
|
|
354c914c43 | ||
|
|
c57d3a333d | ||
|
|
16926ffb00 | ||
|
|
f14b59e63b | ||
|
|
8d93eece1e | ||
|
|
2a1fcfeed6 | ||
|
|
18784ba9f7 | ||
|
|
3220a323ef | ||
|
|
30cc49de3c | ||
|
|
314583ceee | ||
|
|
ed795c522f | ||
|
|
bbc2bb889d | ||
|
|
904e340cdd | ||
|
|
5f8b973789 | ||
|
|
463fa6b937 | ||
|
|
dc7372f277 | ||
|
|
a23bc6b5ae | ||
|
|
81c69db17b | ||
|
|
9e6c8aca08 | ||
|
|
4ef89cde72 | ||
|
|
2eaafe6e2a | ||
|
|
deeb489b4e | ||
|
|
fc243d2f21 | ||
|
|
6ecfa0d21c | ||
|
|
3902bbcb37 | ||
|
|
d4a6aeae1d | ||
|
|
38d0729cb3 | ||
|
|
85d44f9e56 | ||
|
|
48e85555f7 | ||
|
|
c9e2543f50 | ||
|
|
9f4a13eae2 | ||
|
|
33748bea36 | ||
|
|
19e77fd261 | ||
|
|
38b7a3a915 | ||
|
|
3af0834bae | ||
|
|
c6518bcfd5 | ||
|
|
a672880e34 | ||
|
|
310da96a63 | ||
|
|
ba901df953 | ||
|
|
e50e8d3e41 | ||
|
|
498af684c9 | ||
|
|
2282f71829 | ||
|
|
f968d7d0b0 | ||
|
|
b3437b0601 | ||
|
|
843c3e1278 | ||
|
|
4db567eebe | ||
|
|
2bbe45e508 | ||
|
|
4b0f02a7c4 | ||
|
|
79143e197d | ||
|
|
14f873bfdd | ||
|
|
53f3fa34e7 | ||
|
|
5841926b25 | ||
|
|
979875fa63 | ||
|
|
93c32b60ba | ||
|
|
0f4ba71219 | ||
|
|
e7cb136c38 | ||
|
|
57234b7a38 | ||
|
|
3e1b66809a | ||
|
|
d886de322c | ||
|
|
bc38cd59e5 | ||
|
|
61f4909bc6 | ||
|
|
98aa75d185 | ||
|
|
ba07f42d09 |
Generated
+13
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -29,6 +29,7 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"colored",
|
||||
"flate2",
|
||||
"keyring",
|
||||
"rustyline",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -41,6 +42,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wait-timeout",
|
||||
"which",
|
||||
"windows-sys 0.52.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -714,6 +716,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.3"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
@@ -32,9 +32,13 @@ tar = "0.4"
|
||||
tempfile = "3"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wait-timeout = "0.2"
|
||||
keyring = "3"
|
||||
which = "7"
|
||||
zip = "0.6"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Console"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
@@ -76,7 +76,7 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
|----------|------|
|
||||
| am list | agents installés (gérés + détectés sur le PATH) |
|
||||
| am list --all | tout le catalogue, avec l'état de chacun |
|
||||
| am search <mot-clé> | recherche par nom, description, catégorie, tag |
|
||||
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
|
||||
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
|
||||
| am status [agent] | état, version, PID, logs |
|
||||
|
||||
@@ -90,29 +90,50 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am install <agent> | installe (dépendances vérifiées et proposées) |
|
||||
| am install <agent> --method pip | choisit une méthode d'installation |
|
||||
| am update <agent> | met à jour vers la dernière version connue |
|
||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) |
|
||||
| am config set settings.default_shell pwsh | modifie la configuration sans éditeur |
|
||||
| am alias add cc claude-code | raccourci pour un agent (list / remove aussi) |
|
||||
| am init | génère un agent-manager.yaml selon la pile du projet (Node/Rust/Python…) |
|
||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
|
||||
| am export / am import | sauvegarde et restaure config + état |
|
||||
|
||||
### 🚀 Exécuter
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| am start <agent> | avant-plan (Ctrl-C pour quitter) |
|
||||
| am start <agent> --background --env KEY=VALEUR | détaché, PID et log gérés |
|
||||
| am start [agent] | avant-plan (Ctrl-C pour quitter) — sans argument, lance l'agent par défaut du projet |
|
||||
| am start <agent> --background --env KEY=VALEUR | détaché, PID et log gérés ; --env NOM=@secret injecte un secret du trousseau |
|
||||
| am stop <agent> | arrêt gracieux puis forcé (--force : immédiat) |
|
||||
| am restart <agent> | arrête puis redémarre |
|
||||
| am run <agent> [args…] | exécution directe, sans gestion de processus |
|
||||
|
||||
### 📊 Observer & retrouver
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| am stats [agent] --period 7d/30d/90d | statistiques d'utilisation : lancements, durées, échecs, barres ASCII |
|
||||
| am top --period | les 10 agents les plus utilisés |
|
||||
| am report --last-week / --last-month | digest markdown (lancements, échecs, changements, sessions) |
|
||||
| am log [agent] --kind --since --follow | journal des événements : tout ce que am a fait, en direct avec --follow |
|
||||
| am timeline --project --since | vue chronologique unifiée (journal + commandes du REPL) |
|
||||
| am projects [nom] | quel agent travaille sur quel projet (agent dominant, sessions, durées) |
|
||||
| am sessions [agent] --status --show --resume | registre des sessions + reprise d'une session arrêtée |
|
||||
| am history --search --failed --rerun N | historique des commandes (am + shell), filtrable et réexécutable |
|
||||
| am logs <agent> --follow | tail du log d'un agent, en direct |
|
||||
|
||||
### 🧰 Système
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| am doctor | vérifie outils, config, chemins — avec commandes d'installation |
|
||||
| am doctor --fix | répare ce qui peut l'être, propose d'installer les outils manquants |
|
||||
| am config show / edit / add | gère votre configuration |
|
||||
| am watch <agent> --restart --notify | supervise un agent et le relance en cas de crash |
|
||||
| am secret set NOM --agent A --value … / list / unset | secrets dans le trousseau du système (jamais en clair) |
|
||||
| am config show / edit / add / set | gère votre configuration (hooks on_start/on_stop/… dans settings.hooks) |
|
||||
| am open <agent> | ouvre le répertoire d'installation dans l'explorateur |
|
||||
| am completion <shell> | script de complétion (bash, zsh, fish, powershell) |
|
||||
| am self-update | met à jour am lui-même (si configuré) |
|
||||
| am | shell interactif avec Tab et historique |
|
||||
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
|
||||
| am | shell interactif : bannière, passerelle système, Tab et historique |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,10 +187,70 @@ Disponibles avant **ou après** la sous-commande.
|
||||
am start group:dev # démarre tout un groupe en arrière-plan
|
||||
am stop group:dev
|
||||
|
||||
am # shell interactif :
|
||||
am> inst<Tab> # complétion Tab : commandes, agents, groupes, options
|
||||
am> install jcode
|
||||
am> exit # historique persistant (flèches haut/bas)
|
||||
am # shell interactif (bannière style Hermes) :
|
||||
❯ inst<Tab> # complétion Tab : commandes, agents, groupes, options
|
||||
❯ install jcode
|
||||
❯ exit # historique persistant (flèches haut/bas)
|
||||
|
||||
### 🐚 Passerelle système
|
||||
|
||||
Le prompt interactif est aussi une **passerelle vers votre shell
|
||||
système** : toute saisie qui n'est pas une commande am est exécutée par le
|
||||
shell actif. Le **shell par défaut de l'utilisateur est détecté au
|
||||
démarrage et mis en évidence** dans la bannière (config
|
||||
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
|
||||
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
|
||||
|
||||
❯ ls # liste façon Nushell : tableau encadré (type, taille, date)
|
||||
❯ dir # sur Windows, même affichage que ls
|
||||
❯ ls src # un chemin précis — Tab complète fichiers et dossiers
|
||||
❯ ps # liste des processus (pid, ppid, cpu, mem, threads)
|
||||
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
|
||||
❯ ps | where name =~ am # =~ cherche dans le texte
|
||||
❯ ls | get name # sélectionne une colonne
|
||||
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
|
||||
❯ shell # affiche le shell courant + les shells disponibles
|
||||
❯ shell bash # change le shell de la session
|
||||
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
|
||||
❯ cd ~/projets # change le répertoire de la session (persistant)
|
||||
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
|
||||
|
||||
Le menu Tab affiche une **description** à côté de chaque candidat
|
||||
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
|
||||
chaque Tab.
|
||||
|
||||
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
|
||||
`config.yaml` (voir `am config path`).
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Désinstallation propre
|
||||
|
||||
Une seule commande, sur tous les OS — elle arrête d'abord les agents en
|
||||
arrière-plan, puis supprime les données, l'état (logs, historique), la
|
||||
configuration, et enfin l'exécutable lui-même :
|
||||
|
||||
am self-uninstall # avec confirmation
|
||||
am self-uninstall --yes # automatique, en une ligne
|
||||
|
||||
Sans le binaire (ou en dernier recours), supprimez les répertoires
|
||||
qu'agent-manager possède :
|
||||
|
||||
# Linux
|
||||
rm -rf ~/.local/share/agent-manager ~/.local/state/agent-manager ~/.config/agent-manager
|
||||
|
||||
# macOS
|
||||
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
|
||||
|
||||
# Windows — PowerShell
|
||||
Remove-Item -Recurse -Force $env:LOCALAPPDATA\agent-manager, $env:APPDATA\agent-manager -ErrorAction SilentlyContinue
|
||||
|
||||
# Windows — cmd.exe (à ne PAS coller dans PowerShell : rmdir y est un
|
||||
# alias de Remove-Item et le & final lance un job en arrière-plan)
|
||||
rmdir /s /q "%LOCALAPPDATA%\agent-manager" & rmdir /s /q "%APPDATA%\agent-manager"
|
||||
|
||||
(Si vous utilisiez `AGENT_MANAGER_DATA`/`AGENT_MANAGER_STATE`/`AGENT_MANAGER_CONFIG_DIR`,
|
||||
supprimez aussi ces emplacements.)
|
||||
|
||||
---
|
||||
|
||||
@@ -233,6 +314,21 @@ multi-fichiers pris en charge ; configuration validée au démarrage.
|
||||
(Windows) — l'état et les logs dans ~/.local/state/agent-manager (Linux) ou
|
||||
%LOCALAPPDATA%\\agent-manager (Windows).
|
||||
|
||||
**Où vivent les statistiques, les sessions et l'historique ?** Dans le
|
||||
répertoire d'état, au format JSON lisible et 100 % local : events-YYYYMM.jsonl
|
||||
(journal de chaque action), sessions.json (registre des sessions),
|
||||
history/ (historique structuré du shell), projects.json (agrégats par
|
||||
projet). Interrogeables avec am stats, am top, am log, am timeline,
|
||||
am projects, am sessions et am history.
|
||||
|
||||
**Où vivent les secrets ?** Dans le trousseau de votre système (Windows
|
||||
Credential Manager, macOS Keychain, Linux Secret Service) — jamais dans la
|
||||
configuration ni dans les logs. Injection avec --env NOM=@secret.
|
||||
|
||||
**Puis-je automatiser des actions ?** Oui : hooks on_install/on_start/on_stop/
|
||||
on_update dans settings.hooks (globaux) ou dans le profil d'un projet, et
|
||||
am watch pour superviser un agent avec redémarrage automatique.
|
||||
|
||||
**Comment mettre à jour am ?** Relancez l'installeur one-liner (ou
|
||||
am self-update si le dépôt de release est configuré).
|
||||
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
||||
|
||||
> **Version du document : 2.1** · Statut : propositions, non engagées
|
||||
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
||||
>
|
||||
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
||||
> 157 tests, release publiée sur Gitea. Les découpages des phases 1 à 3 sont
|
||||
> en place sur Gitea (issues #25 à #80) : v0.4.0, v0.5.0, v1.0.
|
||||
>
|
||||
> 🔵 **Phase 1 livrée partiellement en v0.4.0 (2026-08-17)** : 13/17 issues
|
||||
> clôturées (#26–#32, #34–#38), 187 tests. Restent : #33 dashboard TUI,
|
||||
> #39 favoris/notes/tags, #40 profils d'environnement, #41 complétions+man,
|
||||
> #42 packaging.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Sommaire
|
||||
|
||||
1. [Résumé exécutif](#1--résumé-exécutif)
|
||||
2. [Légende de lecture](#2--légende-de-lecture)
|
||||
3. [Quick wins — valeur immédiate](#3--quick-wins--valeur-immédiate)
|
||||
4. [État des lieux](#4--état-des-lieux)
|
||||
5. [Vision & principes](#5--vision--principes)
|
||||
6. [Socle technique : le modèle de données](#6--socle-technique--le-modèle-de-données)
|
||||
7. [Les 12 axes](#7--les-12-axes)
|
||||
8. [Jalons versionnés](#8--jalons-versionnés)
|
||||
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
|
||||
10. [Risques & garde-fous](#10--risques--garde-fous)
|
||||
11. [Par où commencer](#11--par-où-commencer)
|
||||
|
||||
---
|
||||
|
||||
## 1. 📌 Résumé exécutif
|
||||
|
||||
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
|
||||
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
|
||||
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
|
||||
|
||||
Mais il est **aveugle** : il ne retient ni ce qui a été fait, ni quand, ni
|
||||
par quel agent, ni dans quel projet, ni avec quel résultat. Les logs sont du
|
||||
texte libre non interrogeable, l'état ne contient que les installations et
|
||||
le PID courant, l'historique est un simple fichier plat.
|
||||
|
||||
Cette roadmap transforme **am** en **cockpit de votre parc d'agents IA** :
|
||||
|
||||
| Rôle | Promesse | Porté par |
|
||||
|---|---|---|
|
||||
| 📓 Journal de bord | tout est tracé, rien ne se perd | Axe 1 (journal d'événements) |
|
||||
| 🔎 Rétroviseur | tout est cherchable en 2 secondes | Axes 2, 3, 4 |
|
||||
| 📊 Tableau de bord | tout est mesuré | Axes 1, 5, 9 |
|
||||
| 🤖 Copilote | il pilote à votre place | Axes 6, 7 |
|
||||
|
||||
**Chiffres clés :** 12 axes · ~55 fonctionnalités · 4 jalons (v0.3.0 → v1.0)
|
||||
· ~15 semaines de travail cumulé · 0 dépendance runtime ajoutée.
|
||||
|
||||
---
|
||||
|
||||
## 2. 📚 Légende de lecture
|
||||
|
||||
| Symbole | Signification |
|
||||
|---|---|
|
||||
| **P0** | Fondations — à démarrer tout de suite (socle des autres axes) |
|
||||
| **P1** | Court terme — après la phase 0 |
|
||||
| **P2** | Moyen terme |
|
||||
| **P3** | Vision — long terme |
|
||||
| ⭐ | Reprend une demande explicite de l'utilisateur |
|
||||
| **S** | Effort : moins d'1 jour |
|
||||
| **M** | Effort : 2 à 4 jours |
|
||||
| **L** | Effort : 1 à 2 semaines |
|
||||
| **XL** | Effort : plus de 2 semaines |
|
||||
| ⬜ / 🔨 / ✅ | Proposé / En cours / Livré (rien n'est commencé aujourd'hui) |
|
||||
|
||||
---
|
||||
|
||||
## 3. ⚡ Quick wins — valeur immédiate
|
||||
|
||||
*Tous en effort S, phase P0, sans dépendance sur le socle. Livrables en
|
||||
quelques jours, à caler avant ou pendant la construction du journal.*
|
||||
|
||||
| Fonctionnalité | Commande | Apport |
|
||||
|---|---|---|
|
||||
| Voir les logs d'un agent | am logs claude-code (--follow) | tail des logs déjà écrits par process.rs |
|
||||
| Filtres sur la liste | am list --running · --sort name,version,status | repérage instantané |
|
||||
| Éditer la config sans éditeur | am config set key value | moins de friction YAML |
|
||||
| Alias à la volée | am alias add cc claude-code | sans éditer le fichier |
|
||||
| Historique propre | dédup + horodatage de history.txt | base saine pour l'axe 2 |
|
||||
| Doctor scriptable | am doctor --json | CI et monitoring |
|
||||
| Ouvrir l'installation | am open claude-code | explorer le répertoire de l'agent |
|
||||
| Version scriptable | am version --json | introspection des scripts |
|
||||
|
||||
---
|
||||
|
||||
## 4. 🧭 État des lieux (points d'ancrage dans le code)
|
||||
|
||||
| Brique actuelle | Fichier | Limite aujourd'hui |
|
||||
|---|---|---|
|
||||
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
|
||||
| Installation (9 méthodes), dépendances OS | src/installers/, src/deps.rs, src/toolchain.rs | solide — rien à redire |
|
||||
| État local | src/state.rs (state.json v1) | installations + PID courant uniquement, **aucun historique** |
|
||||
| Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
|
||||
| Processus | src/process.rs | logs non structurés, pas de sessions, pas de métriques |
|
||||
| Journal applicatif | src/output.rs | texte libre horodaté, non interrogeable |
|
||||
| REPL + passerelle shell | src/repl.rs (history.txt, session_id()) | historique plat, pas de recherche ni de stats |
|
||||
| Backup | src/commands/export_import.rs | manuel, pas de synchronisation |
|
||||
|
||||
---
|
||||
|
||||
## 5. 🎯 Vision & principes directeurs
|
||||
|
||||
**Vision :** le cockpit unique du parc d'agents — journal de bord, rétroviseur,
|
||||
tableau de bord et copilote.
|
||||
|
||||
**Principes :**
|
||||
|
||||
1. **Zéro dépendance runtime** — tout est Rust pur et fichiers locaux
|
||||
(JSONL/JSON), jamais de base de données externe.
|
||||
2. **Local par défaut, opt-in pour le distant** — la télémétrie n'existe que
|
||||
si l'utilisateur l'active ; tout le reste est 100 % local.
|
||||
3. **Contrats stables** — les sorties --json sont la base des scripts et des
|
||||
futurs UI : champs ajoutés, jamais retirés.
|
||||
4. **Performance** — écriture en append (JSONL), lectures par fenêtres
|
||||
temporelles, index dérivés régénérables, caches sur le modèle de probe.rs.
|
||||
5. **Livraison continue** — chaque phase produit un binaire utilisable et
|
||||
testé ; la barre des 45+ tests monte à chaque jalon.
|
||||
|
||||
---
|
||||
|
||||
## 6. 🧱 Socle technique : le modèle de données
|
||||
|
||||
*Tout dépend de ceci. À construire en premier, avant les commandes visibles.*
|
||||
|
||||
state_dir/
|
||||
state.json # v2 : + sessions_count, last_used (migration auto depuis v1)
|
||||
events.jsonl # NOUVEAU — journal d'événements (source de vérité)
|
||||
events-202608.jsonl # rotation mensuelle
|
||||
sessions.json # NOUVEAU — registre des sessions (index dérivé)
|
||||
history/
|
||||
<session>.jsonl # NOUVEAU — historique structuré du REPL
|
||||
projects.json # NOUVEAU — agrégats par projet (recalculable)
|
||||
probe-cache.json # existant — pattern de cache à suivre
|
||||
|
||||
| Fichier | Rôle | Alimenté par | Reconstruction |
|
||||
|---|---|---|---|
|
||||
| events.jsonl | faits bruts (start, stop, run, install…) | process.rs, run_cmd.rs, install_cmd.rs, repl.rs, doctor_cmd.rs | source de vérité, jamais reconstruit |
|
||||
| sessions.json | index des sessions | dérivé du journal | doctor --fix (rejoue le journal) |
|
||||
| projects.json | agrégats par projet | dérivé du journal | doctor --fix |
|
||||
| history/*.jsonl | commandes REPL | repl.rs | non (données primaires) |
|
||||
| state.json v2 | installations + résumé | state.rs | partiellement (compteurs depuis le journal) |
|
||||
|
||||
**Règles :** JSONL append-only pour les faits · JSON dérivés régénérables ·
|
||||
migration additive (state v1 reste lisible partout) · doctor --fix répare
|
||||
tout ce qui peut l'être.
|
||||
|
||||
---
|
||||
|
||||
## 7. 🗂️ Les 12 axes
|
||||
|
||||
### Axe 1 — 📊 Observabilité & statistiques d'utilisation ⭐
|
||||
|
||||
🎯 Savoir **quoi** a tourné, **quand**, **combien de temps**, avec **quel
|
||||
taux de succès** — et **combien ça coûte**.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Journal d'événements events.jsonl (toutes les actions de am) | M | P0 |
|
||||
| am stats + --period 7d/30d/90d + --json (tableaux et barres ASCII) | M | P0 |
|
||||
| am top — classement des agents par utilisation | S | P1 |
|
||||
| am log — filtres agent/type/date, --follow | S → M | P0 → P1 |
|
||||
| am report — rapport hebdo/mensuel en markdown (top agents, échecs, changements) | M | P1 |
|
||||
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) | M | P2 |
|
||||
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes | L | P2 |
|
||||
| Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
|
||||
|
||||
am stats # vue globale
|
||||
am stats claude-code --period 30d
|
||||
am top # qui tourne le plus
|
||||
am report --last-week # digest markdown
|
||||
|
||||
### Axe 2 — 💾 Historique des commandes ⭐
|
||||
|
||||
🎯 Gérer, **rechercher** et **réutiliser** tout ce qui a été tapé.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Historique structuré : history/<session>.jsonl (cmd, kind am/shell, cwd, durée, exit, agent) + migration de history.txt | M | P0 |
|
||||
| am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 |
|
||||
| Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 |
|
||||
| am history --rerun 42 (réexécution confirmée) | S | P1 |
|
||||
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml | L | P2 |
|
||||
|
||||
am history --search "install" --failed
|
||||
am history --rerun 42
|
||||
|
||||
### Axe 3 — 🧭 Projets & workspaces ⭐
|
||||
|
||||
🎯 Centraliser **quel agent travaille sur quel projet** — automatiquement.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Contexte auto au lancement : racine + branche git, stack détectée (Cargo.toml, package.json…), mis en cache | S | P0 |
|
||||
| am projects / am projects api — agents, sessions, durées, dernière activité | M | P1 |
|
||||
| Profils de projet dans config : default_agent, env, hooks | M | P1 |
|
||||
| am start (sans argument) = agent par défaut du dossier courant ; REPL contextuel | S | P1 |
|
||||
| am init — génère un agent-manager.yaml selon la stack détectée | S → M | P0 |
|
||||
| Templates : am init --template web | M | P2 |
|
||||
|
||||
am projects
|
||||
am projects api
|
||||
am init # dans un projet Node → config pré-remplie
|
||||
|
||||
### Axe 4 — 🗂️ Sessions centralisées ⭐
|
||||
|
||||
🎯 Un registre unique de **toutes les sessions de tous les agents** —
|
||||
consultable, reprenable, archivable.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| sessions.json + session_id() partagé (repl.rs) ; fin de session avec exit code et durée | M | P0 |
|
||||
| Réconciliation au démarrage : sessions interrompues (crash, reboot) marquées et datées | S | P0 |
|
||||
| am sessions — filtres agent/projet/statut ; show = résumé + extrait de log | M | P0 |
|
||||
| am timeline — vue chronologique unifiée de toute l'activité | M | P1 |
|
||||
| am sessions resume <id> — relance avec les mêmes args/env/cwd | M | P1 |
|
||||
| am sessions export <id> + rétention configurable (settings.sessions_retention_days) | M | P2 |
|
||||
|
||||
am sessions --status failed
|
||||
am sessions show 20260815_143926_a1b2c3
|
||||
am timeline --project api
|
||||
|
||||
### Axe 5 — 🖥️ Tableau de bord
|
||||
|
||||
🎯 Voir l'état du parc d'un coup d'œil.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| am dashboard — TUI (ratatui/crossterm) : vue d'ensemble, activité en direct, stats, sessions, projets | L | P1 |
|
||||
| am web — serveur local + API JSON + page HTML embarquée avec graphiques | XL | P2 |
|
||||
|
||||
### Axe 6 — 🤖 Automatisation & orchestration
|
||||
|
||||
🎯 Laisser am **superviser et piloter seul** le parc.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 |
|
||||
| am watch claude-code --restart-on-crash --notify | M | P1 |
|
||||
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart | L | P2 |
|
||||
| Planification : am schedule --daily update --all + check santé (cron / Task Scheduler) | M | P2 |
|
||||
| Orchestration de groupes : ordre, --parallel, attente de santé | L | P2 |
|
||||
| Conteneurs : profil docker/podman par agent (isolation à la demande) | L | P2 |
|
||||
| am doctor --watch — vérifications périodiques avec alertes | M | P2 |
|
||||
|
||||
### Axe 7 — 🧠 Intelligence & catalogue
|
||||
|
||||
🎯 Trouver le bon agent, suivre leurs sorties, et comparer.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
|
||||
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 |
|
||||
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 |
|
||||
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
|
||||
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
|
||||
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
|
||||
| Registre communautaire : publier son catalogue (Gitea) + am registry | L | P3 |
|
||||
| am ask "installe claude et lance-le" — langage naturel → commande am (fournisseur LLM configurable, optionnel) | L | P3 |
|
||||
|
||||
### Axe 8 — 🔒 Sécurité & gouvernance
|
||||
|
||||
🎯 Protéger les secrets, tracer les changements, pouvoir revenir en arrière.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| am secret set OPENAI_KEY --agent claude-code (keyring OS) + injection --env automatique | M | P1 |
|
||||
| am audit — qui a modifié quoi quand (checksums des configs, événements) | M | P2 |
|
||||
| am update --rollback — backup automatique avant chaque mise à jour | M | P2 |
|
||||
| Politiques : pin de version, settings.update_policy | S | P2 |
|
||||
| Profils sandbox par agent (commandes/répertoires autorisés) | L | P3 |
|
||||
|
||||
### Axe 9 — 🌐 Multi-machine & collaboration
|
||||
|
||||
🎯 Retrouver son cockpit partout, et le partager.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 |
|
||||
| Partage de catalogue d'équipe : include par URL | S | P2 |
|
||||
| am migrate — assistant de transfert machine A → B | M | P2 |
|
||||
| am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
|
||||
|
||||
### Axe 10 — 🧩 Confort & personnalisation
|
||||
|
||||
🎯 Adapter am à sa façon de travailler.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
|
||||
| Notes : am note claude-code "utiliser pour …" | S | P1 |
|
||||
| Tags personnels : am tag claude-code python | S | P1 |
|
||||
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 |
|
||||
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
|
||||
| Thèmes couleurs du REPL | S | P2 |
|
||||
|
||||
### Axe 11 — 🎛️ Modèles locaux
|
||||
|
||||
🎯 Étendre le cockpit aux modèles : **ollama, llama.cpp, LM Studio**.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| am models — inventaire : nom, taille disque, quantisation, dernière utilisation | M | P2 |
|
||||
| Lien agent ↔ modèle : am run mon-agent --model llama3.1 | M | P2 |
|
||||
| doctor vérifie ollama / llama-server comme n'importe quel outil | S | P2 |
|
||||
| am models prune — purge des modèles inutilisés | S | P2 |
|
||||
|
||||
### Axe 12 — 🛠️ Écosystème & expérience développeur
|
||||
|
||||
🎯 Rendre am disponible partout et contribuable.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Fixtures d'événements rejouables + tests de bout en bout du journal | S | P0 |
|
||||
| Packaging officiel : winget, scoop, deb/rpm, Homebrew + CI de release | M | P1 |
|
||||
| Complétions dynamiques (agents installés, groupes personnalisés) | S | P1 |
|
||||
| Man pages + documentation générée | S | P1 |
|
||||
| i18n : messages EN/FR | L | P2 |
|
||||
| Plugin scripts (hooks avancés, intégration CI) | M | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 🚀 Jalons versionnés
|
||||
|
||||
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|
||||
|---|---|---|---|---|
|
||||
| **J0 — Fondations** | v0.3.0 | events.jsonl, sessions, am stats, am log, am history, am init, quick wins | ≈ 2 sem | 100 % des actions tracées · 100+ tests |
|
||||
| **J1 — Cockpit** | v0.4.0 | recherche fuzzy, Ctrl-R, projects, timeline, dashboard TUI, hooks, secret, favoris, packaging | ≈ 3 sem | retrouver n'importe quelle action passée en < 2 s |
|
||||
| **J2 — Automatisation** | v0.5.0 | watch, services, schedule, news/compare, catalogue distant, lab, models, sync | ≈ 4 sem | parc auto-supervisé (crash = redémarrage + alerte) |
|
||||
| **J3 — Plateforme** | v1.0 | am web, am serve, am ask, registry, télémétrie opt-in, sandbox | ≈ 6 sem | dashboard web complet + API distante |
|
||||
|
||||
---
|
||||
|
||||
## 9. 📈 Indicateurs de succès (KPI)
|
||||
|
||||
| Indicateur | Cible |
|
||||
|---|---|
|
||||
| Part des actions tracées dans events.jsonl | 100 % dès la v0.3.0 |
|
||||
| Temps pour retrouver une commande ou session passée | < 2 s |
|
||||
| am list à chaud, journal actif | < 100 ms |
|
||||
| Dépendances runtime ajoutées | 0 |
|
||||
| Couverture de tests | ≥ 100 tests en v0.3, +25 par jalon |
|
||||
| Contrats --json cassés entre versions mineures | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 10. ⚠️ Risques & garde-fous
|
||||
|
||||
1. **ADN « zéro runtime »** : toute dépendance nouvelle est compilée dans le
|
||||
binaire, jamais requise à l'exécution.
|
||||
2. **Performance** : le journal grossit — fenêtres temporelles, index dérivés,
|
||||
rotation mensuelle, caches façon probe.rs.
|
||||
3. **Vie privée** : local par défaut ; télémétrie opt-in, agrégée, anonyme.
|
||||
4. **Contrats --json** : ajout de champs sans retrait (scripts utilisateurs).
|
||||
5. **Compatibilité** : migrations additives ; les exports 0.2.x restent
|
||||
importables en 0.3+.
|
||||
6. **Complexité UX** : les fonctions avancées restent derrière
|
||||
settings.experimental jusqu'à maturité.
|
||||
7. **Périmètre** : rester un orchestrateur — ne pas réimplémenter les
|
||||
fonctionnalités propres aux agents (tokens, MCP, prompts), sauf valeur
|
||||
transversale (stats, sessions, secrets, coûts).
|
||||
|
||||
---
|
||||
|
||||
## 11. 🏁 Par où commencer
|
||||
|
||||
*Le suivi est en place sur Gitea : 4 milestones (v0.3.0 → v1.0), 22 labels
|
||||
(P0–P3, axe-1…axe-12, S/M/L/XL, quick-win, epic) et les issues ci-dessous.
|
||||
Chaque PR doit référencer son issue avec `closes #N` pour une clôture
|
||||
automatique. Ordre de construction proposé pour la phase 0 — chaque étape
|
||||
est un PR indépendant et testé :*
|
||||
|
||||
1. **[PR 1](https://git.dracodev.net/Projets/agent-manager/issues/2)** — src/events.rs : journal JSONL + rotation + tests.
|
||||
2. **[PR 2](https://git.dracodev.net/Projets/agent-manager/issues/3)** — instrumentation start/stop/run (process.rs, run_cmd.rs) — dépend de PR 1.
|
||||
3. **[PR 3](https://git.dracodev.net/Projets/agent-manager/issues/4)** — state.json v2 + migration additive — dépend de PR 1.
|
||||
4. **[PR 4](https://git.dracodev.net/Projets/agent-manager/issues/5)** — sessions.json + am sessions + réconciliation — dépend de PR 2.
|
||||
5. **[PR 5](https://git.dracodev.net/Projets/agent-manager/issues/6)** — am stats + am log (lecture du journal) — dépend de PR 2.
|
||||
6. **[PR 6](https://git.dracodev.net/Projets/agent-manager/issues/7)** — historique structuré + am history (migration de history.txt).
|
||||
7. **[PR 7](https://git.dracodev.net/Projets/agent-manager/issues/8)** — am init + détection de contexte (git, stack, cache) — dépend de PR 1.
|
||||
8. **En parallèle** — les quick wins de la section 3 :
|
||||
[am logs #9](https://git.dracodev.net/Projets/agent-manager/issues/9) ·
|
||||
[am list --sort #10](https://git.dracodev.net/Projets/agent-manager/issues/10) ·
|
||||
[am config set #11](https://git.dracodev.net/Projets/agent-manager/issues/11) ·
|
||||
[am alias add #12](https://git.dracodev.net/Projets/agent-manager/issues/12) ·
|
||||
[dedup history.txt #13](https://git.dracodev.net/Projets/agent-manager/issues/13) ·
|
||||
[am doctor --json #14](https://git.dracodev.net/Projets/agent-manager/issues/14) ·
|
||||
[am version --json #15](https://git.dracodev.net/Projets/agent-manager/issues/15) ·
|
||||
[am open #16](https://git.dracodev.net/Projets/agent-manager/issues/16).
|
||||
|
||||
Épique de suivi : [issue #1](https://git.dracodev.net/Projets/agent-manager/issues/1) (Phase 0).
|
||||
|
||||
### Phase 1 — Cockpit (v0.4.0) — découpage créé le 2026-08-17
|
||||
|
||||
Épique : [issue #25](https://git.dracodev.net/Projets/agent-manager/issues/25) · milestone v0.4.0 · 17 issues.
|
||||
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#26](https://git.dracodev.net/Projets/agent-manager/issues/26) | Recherche fuzzy dans am search | M |
|
||||
| [#27](https://git.dracodev.net/Projets/agent-manager/issues/27) | Ctrl-R + réexécution dans le REPL | M |
|
||||
| [#28](https://git.dracodev.net/Projets/agent-manager/issues/28) | am history --rerun N | S |
|
||||
| [#29](https://git.dracodev.net/Projets/agent-manager/issues/29) | am projects + profils de projet | M |
|
||||
| [#30](https://git.dracodev.net/Projets/agent-manager/issues/30) | am start contextuel (dépend de #29) | S |
|
||||
| [#31](https://git.dracodev.net/Projets/agent-manager/issues/31) | am timeline (vue chronologique unifiée) | M |
|
||||
| [#32](https://git.dracodev.net/Projets/agent-manager/issues/32) | am sessions resume <id> | M |
|
||||
| [#33](https://git.dracodev.net/Projets/agent-manager/issues/33) | am dashboard (TUI temps réel) | L |
|
||||
| [#34](https://git.dracodev.net/Projets/agent-manager/issues/34) | Hooks on_install/on_start/on_stop/on_update | M |
|
||||
| [#35](https://git.dracodev.net/Projets/agent-manager/issues/35) | am watch --restart-on-crash --notify | M |
|
||||
| [#36](https://git.dracodev.net/Projets/agent-manager/issues/36) | am secret (keyring OS) + injection --env | M |
|
||||
| [#37](https://git.dracodev.net/Projets/agent-manager/issues/37) | am top + am report (rapport hebdo/mensuel) | M |
|
||||
| [#38](https://git.dracodev.net/Projets/agent-manager/issues/38) | am log --follow (flux en direct) | S |
|
||||
| [#39](https://git.dracodev.net/Projets/agent-manager/issues/39) | Favoris, notes et tags personnels | M |
|
||||
| [#40](https://git.dracodev.net/Projets/agent-manager/issues/40) | Profils d'environnement (am profile dev/prod) | M |
|
||||
| [#41](https://git.dracodev.net/Projets/agent-manager/issues/41) | Complétions dynamiques + man pages | S |
|
||||
| [#42](https://git.dracodev.net/Projets/agent-manager/issues/42) | Packaging officiel (winget/scoop/deb/Homebrew) + CI | M |
|
||||
|
||||
Critère de sortie du jalon : retrouver n'importe quelle action passée en
|
||||
moins de 2 secondes.
|
||||
|
||||
### Phase 2 — Automatisation (v0.5.0) — découpage créé le 2026-08-17
|
||||
|
||||
Épique : [issue #47](https://git.dracodev.net/Projets/agent-manager/issues/47) · milestone v0.5.0 · 27 issues.
|
||||
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M |
|
||||
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) | L |
|
||||
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L |
|
||||
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | Templates — am init --template web | M |
|
||||
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | am sessions export + rétention configurable | M |
|
||||
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
|
||||
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
|
||||
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
|
||||
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L |
|
||||
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L |
|
||||
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M |
|
||||
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | Catalogue distant — am catalog update / am catalog add <url> | M |
|
||||
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | am suggest — « un agent pour du Python » (tags + usage réel) | M |
|
||||
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
|
||||
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
|
||||
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
|
||||
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | Politiques — pin de version, settings.update_policy | S |
|
||||
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
|
||||
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | Partage de catalogue d'équipe (include par URL) | S |
|
||||
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M |
|
||||
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | Thèmes couleurs du REPL | S |
|
||||
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
|
||||
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | Lien agent ↔ modèle — am run --model | M |
|
||||
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | doctor vérifie ollama / llama-server comme n'importe quel outil | S |
|
||||
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | am models prune — purge des modèles inutilisés | S |
|
||||
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L |
|
||||
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | Plugin scripts (hooks avancés, intégration CI) | M |
|
||||
|
||||
Critère de sortie du jalon : parc auto-supervisé (crash = redémarrage +
|
||||
alerte).
|
||||
|
||||
### Phase 3 — Plateforme (v1.0) — découpage créé le 2026-08-17
|
||||
|
||||
Épique : [issue #48](https://git.dracodev.net/Projets/agent-manager/issues/48) · milestone v1.0 · 5 issues.
|
||||
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
|
||||
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
|
||||
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
|
||||
|
||||
Critère de sortie du jalon : dashboard web complet + API distante.
|
||||
|
||||
---
|
||||
|
||||
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
|
||||
La phase 0 est livrée (v0.3.0) ; les découpages des phases 1 (v0.4.0),
|
||||
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).*
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Record build metadata (git commit and branch) at compile time so the
|
||||
//! 'am version' command can display it, like Nushell's version table.
|
||||
//!
|
||||
//! When git is unavailable (e.g. building from a source tarball) the values
|
||||
//! are simply empty.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn git(args: &[&str]) -> String {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads");
|
||||
let commit = git(&["rev-parse", "HEAD"]);
|
||||
let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
// A detached HEAD reports the literal branch name "HEAD".
|
||||
let branch = if branch == "HEAD" {
|
||||
String::new()
|
||||
} else {
|
||||
branch
|
||||
};
|
||||
println!("cargo:rustc-env=AM_GIT_COMMIT={commit}");
|
||||
println!("cargo:rustc-env=AM_GIT_BRANCH={branch}");
|
||||
}
|
||||
+21
-9
@@ -14,7 +14,7 @@ version: "1.0"
|
||||
settings:
|
||||
install_dir: null # default: ~/.local/share/agent-manager/agents
|
||||
log_dir: null # default: ~/.local/state/agent-manager/logs
|
||||
default_shell: null # default: sh on Unix
|
||||
default_shell: null # install scripts + shell gateway (default: user's shell)
|
||||
auto_install_deps: true # offer to install missing dependencies
|
||||
confirm_before_run: true # confirm before running install scripts
|
||||
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
|
||||
@@ -56,6 +56,19 @@ agents:
|
||||
run: claude
|
||||
tags: [anthropic, assistant, source-available, mainstream]
|
||||
|
||||
- name: deepseek-harness
|
||||
display_name: "DeepSeek Harness"
|
||||
description: "CLI officiel DeepSeek (dsh) : boot de profils, gestion des plugins Cordis, orchestration multi-agents, boucles de buts (goals) et interface web. Installez-le pour piloter vos agents depuis am."
|
||||
category: coding-agent
|
||||
website: https://github.com/deepseek-ai/deepseek-harness
|
||||
install:
|
||||
type: npm
|
||||
package: "@deepseek-ai/dsh"
|
||||
dependencies:
|
||||
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
|
||||
run: dsh
|
||||
tags: [deepseek, harness, multi-agent, plugins, web-ui]
|
||||
|
||||
- name: hermes-agent
|
||||
display_name: "Hermes Agent"
|
||||
description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)."
|
||||
@@ -328,14 +341,14 @@ agents:
|
||||
|
||||
- name: kimi-cli
|
||||
display_name: "Kimi CLI"
|
||||
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0)."
|
||||
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0). Installé via uv (paquet PyPI kimi-cli)."
|
||||
category: coding-agent
|
||||
website: https://github.com/MoonshotAI/kimi-cli
|
||||
install:
|
||||
type: npm
|
||||
package: "@moonshotai/kimi-cli"
|
||||
type: uv
|
||||
package: kimi-cli
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
- { name: uv, min_version: "0.4.0", install_hint: "https://docs.astral.sh/uv" }
|
||||
run: kimi
|
||||
tags: [moonshot, skills, mcp]
|
||||
|
||||
@@ -816,10 +829,9 @@ agents:
|
||||
category: assistant
|
||||
website: https://github.com/sipeed/picoclaw
|
||||
install:
|
||||
type: git
|
||||
repo: "https://github.com/sipeed/picoclaw"
|
||||
dependencies:
|
||||
- { name: go, min_version: "1.21.0", install_hint: "https://go.dev/dl" }
|
||||
type: binary
|
||||
repo: sipeed/picoclaw
|
||||
binary: picoclaw
|
||||
run: picoclaw
|
||||
tags: [go, tiny]
|
||||
|
||||
|
||||
+51
-1
@@ -6,7 +6,9 @@ use crate::cli::Cli;
|
||||
use crate::config::{self, Config, Settings};
|
||||
use crate::output::Logger;
|
||||
use crate::state::StateStore;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::theme::{self, Theme};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::cell::Cell;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -19,6 +21,9 @@ pub struct App {
|
||||
pub catalog: Catalog,
|
||||
pub state: StateStore,
|
||||
pub log: Logger,
|
||||
/// Active output color theme (switchable at runtime with the REPL
|
||||
/// 'theme <name>' command).
|
||||
theme: Cell<&'static Theme>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -78,6 +83,7 @@ impl App {
|
||||
loaded.config.aliases.clone(),
|
||||
loaded.config.groups.clone(),
|
||||
);
|
||||
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
|
||||
Ok(Self {
|
||||
cli,
|
||||
config: loaded.config,
|
||||
@@ -86,6 +92,7 @@ impl App {
|
||||
catalog,
|
||||
state,
|
||||
log,
|
||||
theme: Cell::new(theme),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +112,32 @@ impl App {
|
||||
!self.cli.no_color && std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
pub fn theme(&self) -> &'static Theme {
|
||||
self.theme.get()
|
||||
}
|
||||
|
||||
/// Switch the active theme (used by the REPL 'theme <name>' command).
|
||||
pub fn set_theme(&self, theme: &'static Theme) {
|
||||
self.theme.set(theme);
|
||||
}
|
||||
|
||||
/// Directory of the event journal (the state directory).
|
||||
pub fn events_dir(&self) -> PathBuf {
|
||||
self.paths
|
||||
.state_file
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| self.paths.state_file.clone())
|
||||
}
|
||||
|
||||
/// Append an event to the journal; failures are never fatal (logged at
|
||||
/// verbose level only).
|
||||
pub fn emit(&self, event: &crate::events::Event) {
|
||||
if let Err(e) = crate::events::append(&self.events_dir(), event) {
|
||||
self.log.verbose(&format!("cannot write event journal: {e:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
|
||||
pub fn confirm(&self, question: &str) -> Result<bool> {
|
||||
if self.cli.yes {
|
||||
@@ -119,6 +152,23 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the output color theme: the --theme flag wins, then the
|
||||
/// configuration entry, then the default theme. An unknown --theme value is
|
||||
/// an error; an unknown configured value silently falls back to the default.
|
||||
fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> {
|
||||
if let Some(name) = flag {
|
||||
return theme::find(name).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unknown theme '{name}' — available: {}",
|
||||
theme::names().join(", ")
|
||||
)
|
||||
});
|
||||
}
|
||||
Ok(configured
|
||||
.and_then(theme::find)
|
||||
.unwrap_or_else(theme::default_theme))
|
||||
}
|
||||
|
||||
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
|
||||
/// mostly used by tests).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
|
||||
+159
-23
@@ -80,35 +80,125 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// Case-insensitive search over name, display name, description,
|
||||
/// category, tags and note.
|
||||
/// category, tags and note — typo-tolerant, ranked by relevance.
|
||||
pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> {
|
||||
let kw = keyword.to_lowercase();
|
||||
self.agents
|
||||
let mut scored: Vec<(u32, &AgentDef)> = self
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
if a.hidden {
|
||||
return false;
|
||||
}
|
||||
if let Some(cat) = category {
|
||||
if a.category.as_deref() != Some(cat) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let hay = format!(
|
||||
"{} {} {} {} {} {}",
|
||||
a.name,
|
||||
a.title(),
|
||||
a.description.as_deref().unwrap_or(""),
|
||||
a.category.as_deref().unwrap_or(""),
|
||||
a.tags.join(" "),
|
||||
a.note.as_deref().unwrap_or("")
|
||||
)
|
||||
.to_lowercase();
|
||||
hay.contains(&kw)
|
||||
.filter(|a| !a.hidden)
|
||||
.filter(|a| category.map_or(true, |c| a.category.as_deref() == Some(c)))
|
||||
.filter_map(|a| {
|
||||
let name = a.name.to_lowercase();
|
||||
let title = a.title().to_lowercase();
|
||||
let rest = format!(
|
||||
"{} {} {}",
|
||||
a.description.as_deref().unwrap_or("").to_lowercase(),
|
||||
a.category.as_deref().unwrap_or("").to_lowercase(),
|
||||
a.tags.join(" ").to_lowercase()
|
||||
);
|
||||
let score = score_keyword(&kw, &name)
|
||||
.saturating_mul(3)
|
||||
.saturating_add(score_keyword(&kw, &title).saturating_mul(2))
|
||||
.saturating_add(score_keyword(&kw, &rest));
|
||||
(score > 0).then_some((score, a))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then(a.name.cmp(&b.name)));
|
||||
scored.into_iter().map(|(_, a)| a).collect()
|
||||
}
|
||||
|
||||
/// "Did you mean" candidates for a keyword with no results: closest
|
||||
/// agent names and display names by edit distance.
|
||||
pub fn suggest(&self, keyword: &str) -> Vec<String> {
|
||||
let kw = keyword.to_lowercase();
|
||||
let mut out: Vec<(usize, String)> = Vec::new();
|
||||
for a in self.agents.iter().filter(|a| !a.hidden) {
|
||||
for field in [&a.name, &a.title().to_string()] {
|
||||
let f = field.to_lowercase();
|
||||
if f.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let d = levenshtein(&kw, &f);
|
||||
if d <= 3 && !out.iter().any(|(_, n)| n == &a.name) {
|
||||
out.push((d, a.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|(da, na), (db, nb)| da.cmp(db).then(na.cmp(nb)));
|
||||
out.truncate(4);
|
||||
out.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a keyword against one haystack field: 100 for a full hit,
|
||||
/// then subsequence and typo bonuses; 0 = no relation at all.
|
||||
fn score_keyword(keyword: &str, hay: &str) -> u32 {
|
||||
if keyword.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let tokens: Vec<&str> = keyword.split_whitespace().collect();
|
||||
let mut total = 0u32;
|
||||
for token in &tokens {
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let s = score_token(token, hay);
|
||||
if s == 0 {
|
||||
return 0;
|
||||
}
|
||||
total += s;
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn score_token(token: &str, hay: &str) -> u32 {
|
||||
if hay.contains(token) {
|
||||
return 100;
|
||||
}
|
||||
if subsequence(token, hay) {
|
||||
return 40;
|
||||
}
|
||||
// Typo tolerance: compare the token with every word of the field.
|
||||
let mut best = usize::MAX;
|
||||
for word in hay.split(|c: char| !c.is_alphanumeric()) {
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
best = best.min(levenshtein(token, word));
|
||||
}
|
||||
match best {
|
||||
0..=1 => 25,
|
||||
2 => 15,
|
||||
3 => 5,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the characters of needle appear in order inside hay?
|
||||
fn subsequence(needle: &str, hay: &str) -> bool {
|
||||
let mut it = hay.chars();
|
||||
needle.chars().all(|c| it.any(|h| h == c))
|
||||
}
|
||||
|
||||
/// Classic Levenshtein distance on characters.
|
||||
fn levenshtein(a: &str, b: &str) -> usize {
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0usize; b.len() + 1];
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let cost = if ca == cb { 0 } else { 1 };
|
||||
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Sorted list of all categories present in the catalog.
|
||||
pub fn categories(&self) -> Vec<String> {
|
||||
let mut cats: Vec<String> = self
|
||||
@@ -156,4 +246,50 @@ mod tests {
|
||||
let cats = cat.categories();
|
||||
assert!(cats.contains(&"coding-agent".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_search_tolerates_missing_separator() {
|
||||
let cat = catalog();
|
||||
let hits = cat.search("claude cod", None);
|
||||
assert!(
|
||||
hits.iter().any(|a| a.name == "claude-code"),
|
||||
"expected claude-code in {:?}",
|
||||
hits.iter().map(|a| &a.name).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_search_tolerates_typos() {
|
||||
let cat = catalog();
|
||||
let hits = cat.search("cludecode", None);
|
||||
assert!(hits.iter().any(|a| a.name == "claude-code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_matches_rank_first() {
|
||||
let cat = catalog();
|
||||
let hits = cat.search("claude", None);
|
||||
assert!(!hits.is_empty());
|
||||
assert_eq!(
|
||||
hits[0].name, "claude-code",
|
||||
"exact name match must rank first: {:?}",
|
||||
hits.iter().map(|a| &a.name).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggest_proposes_close_names() {
|
||||
let cat = catalog();
|
||||
let sug = cat.suggest("aiderr");
|
||||
assert!(sug.contains(&"aider".to_string()), "suggestions: {sug:?}");
|
||||
assert!(sug.len() <= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levenshtein_distance() {
|
||||
assert_eq!(levenshtein("aider", "aider"), 0);
|
||||
assert_eq!(levenshtein("aider", "aiderr"), 1);
|
||||
assert_eq!(levenshtein("abc", "axc"), 1);
|
||||
assert_eq!(levenshtein("abc", "xyz"), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+229
-3
@@ -10,6 +10,9 @@ use std::path::PathBuf;
|
||||
name = "am",
|
||||
bin_name = "am",
|
||||
version,
|
||||
// The help subcommand is provided by the custom Nushell-style help
|
||||
// (cli::Command::Help) instead of clap's generated one.
|
||||
disable_help_subcommand = true,
|
||||
about = "agent-manager (am) — manage local AI coding agents",
|
||||
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
|
||||
|
||||
@@ -47,6 +50,10 @@ pub struct Cli {
|
||||
#[arg(long, global = true, action = ArgAction::SetTrue)]
|
||||
pub no_color: bool,
|
||||
|
||||
/// Color theme for the output (see 'am help theme' for the list)
|
||||
#[arg(long, global = true, value_name = "THEME")]
|
||||
pub theme: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
@@ -61,7 +68,49 @@ pub enum Command {
|
||||
/// Only show agents of this category
|
||||
#[arg(long, value_name = "CATEGORY")]
|
||||
category: Option<String>,
|
||||
/// Only show running agents
|
||||
#[arg(long)]
|
||||
running: bool,
|
||||
/// Sort by name, version or status (default: name)
|
||||
#[arg(long, value_name = "KEY")]
|
||||
sort: Option<String>,
|
||||
},
|
||||
/// Tail the log file of an agent (--follow for a live view)
|
||||
Logs {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
/// Number of lines to show (default: 20)
|
||||
#[arg(long, value_name = "N", default_value = "20")]
|
||||
lines: usize,
|
||||
/// Follow the log as it grows
|
||||
#[arg(long)]
|
||||
follow: bool,
|
||||
},
|
||||
/// Open an agent's installation directory in the file manager
|
||||
Open {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
},
|
||||
/// Supervise an agent and optionally restart it on crash
|
||||
Watch {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
/// Restart the agent automatically when it dies (with backoff)
|
||||
#[arg(long)]
|
||||
restart: bool,
|
||||
/// Desktop notification on every restart
|
||||
#[arg(long)]
|
||||
notify: bool,
|
||||
/// Check interval in seconds (default: 5)
|
||||
#[arg(long, value_name = "SECS", default_value = "5")]
|
||||
interval: u64,
|
||||
},
|
||||
/// Manage command aliases (cc -> claude-code)
|
||||
#[command(subcommand)]
|
||||
Alias(AliasCmd),
|
||||
/// Manage secrets in the OS keyring (never in plaintext config)
|
||||
#[command(subcommand)]
|
||||
Secret(SecretCmd),
|
||||
/// Start an agent (foreground by default, or detached with --background)
|
||||
Start(StartArgs),
|
||||
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
|
||||
@@ -91,6 +140,85 @@ pub enum Command {
|
||||
/// Agent name or alias (omit to show all installed agents)
|
||||
agent: Option<String>,
|
||||
},
|
||||
/// List the sessions of every agent (and of the interactive shell)
|
||||
Sessions {
|
||||
/// Only sessions of this agent
|
||||
agent: Option<String>,
|
||||
/// Only sessions of this project
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
project: Option<String>,
|
||||
/// Only sessions with this status (running, stopped, failed, interrupted)
|
||||
#[arg(long, value_name = "STATUS")]
|
||||
status: Option<String>,
|
||||
/// Show one session in detail (summary + log excerpt)
|
||||
#[arg(long, value_name = "ID")]
|
||||
show: Option<String>,
|
||||
/// Resume a finished session: relaunch the agent with its recorded arguments
|
||||
#[arg(long, value_name = "ID")]
|
||||
resume: Option<String>,
|
||||
},
|
||||
/// Show usage statistics computed from the event journal
|
||||
Stats {
|
||||
/// Agent name or alias (omit for the global view)
|
||||
agent: Option<String>,
|
||||
/// Only events of the last period (7d, 30d, 90d, all)
|
||||
#[arg(long, value_name = "PERIOD")]
|
||||
period: Option<String>,
|
||||
},
|
||||
/// Show the most used agents (top 10)
|
||||
Top {
|
||||
/// Only events of the last period (7d, 30d, 90d, all)
|
||||
#[arg(long, value_name = "PERIOD")]
|
||||
period: Option<String>,
|
||||
},
|
||||
/// Write a markdown activity report
|
||||
Report {
|
||||
/// Cover the last 7 days
|
||||
#[arg(long, conflicts_with = "last_month")]
|
||||
last_week: bool,
|
||||
/// Cover the last 30 days
|
||||
#[arg(long)]
|
||||
last_month: bool,
|
||||
/// Output file (default: report-YYYYMMDD.md in the state directory)
|
||||
#[arg(long, value_name = "FILE")]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Show which agent works on which project
|
||||
Projects {
|
||||
/// Project name (omit to list every project)
|
||||
name: Option<String>,
|
||||
},
|
||||
/// One chronological view of every activity (journal + REPL history)
|
||||
Timeline {
|
||||
/// Only activity of this agent
|
||||
agent: Option<String>,
|
||||
/// Only activity of this project
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
project: Option<String>,
|
||||
/// Only activity at or after this date (YYYY-MM-DD or RFC 3339)
|
||||
#[arg(long, value_name = "DATE")]
|
||||
since: Option<String>,
|
||||
/// Maximum number of entries (default: 50)
|
||||
#[arg(long, value_name = "N", default_value = "50")]
|
||||
limit: usize,
|
||||
},
|
||||
/// Read the event journal: everything am did
|
||||
Log {
|
||||
/// Only events of this agent
|
||||
agent: Option<String>,
|
||||
/// Only these event kinds (comma separated: start,stop,run,install,...)
|
||||
#[arg(long, value_name = "KINDS")]
|
||||
kind: Option<String>,
|
||||
/// Only events at or after this date (YYYY-MM-DD or RFC 3339)
|
||||
#[arg(long, value_name = "DATE")]
|
||||
since: Option<String>,
|
||||
/// Maximum number of events (default: 50)
|
||||
#[arg(long, value_name = "N", default_value = "50")]
|
||||
limit: usize,
|
||||
/// Follow the journal as new events arrive
|
||||
#[arg(long)]
|
||||
follow: bool,
|
||||
},
|
||||
/// Install an agent and its dependencies
|
||||
Install {
|
||||
/// Agent name or alias
|
||||
@@ -102,7 +230,7 @@ pub enum Command {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Uninstall an agent and clean its files
|
||||
/// Uninstall an agent (managed or external) and clean its files
|
||||
Uninstall {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
@@ -126,11 +254,51 @@ pub enum Command {
|
||||
#[arg(long, value_name = "CATEGORY")]
|
||||
category: Option<String>,
|
||||
},
|
||||
/// Search the command history (am and shell commands)
|
||||
History {
|
||||
/// Only commands of this kind (am or shell)
|
||||
#[arg(long, value_name = "KIND")]
|
||||
kind: Option<String>,
|
||||
/// Only commands run in this directory
|
||||
#[arg(long, value_name = "DIR")]
|
||||
cwd: Option<String>,
|
||||
/// Full-text search in the command line
|
||||
#[arg(long, value_name = "TEXT")]
|
||||
search: Option<String>,
|
||||
/// Only failed commands
|
||||
#[arg(long)]
|
||||
failed: bool,
|
||||
/// Only commands of this session
|
||||
#[arg(long, value_name = "ID")]
|
||||
session: Option<String>,
|
||||
/// Maximum number of entries (default: 100)
|
||||
#[arg(long, value_name = "N", default_value = "100")]
|
||||
limit: usize,
|
||||
/// Re-run the Nth most recent entry (with confirmation)
|
||||
#[arg(long, value_name = "N")]
|
||||
rerun: Option<usize>,
|
||||
},
|
||||
/// Show detailed information about one agent
|
||||
Info {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
},
|
||||
/// Generate a local agent-manager.yaml for the current directory
|
||||
Init {
|
||||
/// Overwrite an existing agent-manager.yaml
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Display help: the overview, a command, the command list, or a search
|
||||
Help {
|
||||
/// Command name (also accepts an agent name or alias); "commands" lists every command
|
||||
command: Option<String>,
|
||||
/// Search through all help commands table
|
||||
#[arg(long, value_name = "TEXT")]
|
||||
find: Option<String>,
|
||||
},
|
||||
/// Display version and build information
|
||||
Version,
|
||||
/// Manage the configuration file
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCmd),
|
||||
@@ -163,6 +331,12 @@ pub enum Command {
|
||||
#[arg(long, value_name = "FILE")]
|
||||
to: Option<PathBuf>,
|
||||
},
|
||||
/// Remove agent-manager and everything it created from this machine
|
||||
///
|
||||
/// Stops every managed background agent, then deletes the data, state and
|
||||
/// configuration directories, and finally the executable itself. Combine
|
||||
/// with --yes for a fully automated one-liner.
|
||||
SelfUninstall,
|
||||
/// Export the configuration and installation state (backup)
|
||||
Export {
|
||||
/// Output file (default: am-export.json)
|
||||
@@ -179,8 +353,8 @@ pub enum Command {
|
||||
/// Arguments shared by start and restart.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct StartArgs {
|
||||
/// Agent name, alias, or group:<name>
|
||||
pub agent: String,
|
||||
/// Agent name, alias, or group:<name> (defaults to the project's default agent)
|
||||
pub agent: Option<String>,
|
||||
/// Run detached in the background; output goes to the agent log file
|
||||
#[arg(short = 'b', long, conflicts_with = "foreground", action = ArgAction::SetTrue)]
|
||||
pub background: bool,
|
||||
@@ -198,6 +372,51 @@ pub struct StartArgs {
|
||||
pub notify: bool,
|
||||
}
|
||||
|
||||
/// Secret management subcommands.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum SecretCmd {
|
||||
/// Store a secret for an agent
|
||||
Set {
|
||||
/// Secret name (the environment variable name)
|
||||
name: String,
|
||||
/// Agent it belongs to
|
||||
#[arg(long, value_name = "AGENT")]
|
||||
agent: String,
|
||||
/// Secret value (prefer --value over shell history; never logged)
|
||||
#[arg(long, value_name = "VALUE")]
|
||||
value: String,
|
||||
},
|
||||
/// Remove a secret
|
||||
Unset {
|
||||
/// Secret name
|
||||
name: String,
|
||||
/// Agent it belongs to
|
||||
#[arg(long, value_name = "AGENT")]
|
||||
agent: String,
|
||||
},
|
||||
/// List stored secret names (values are never shown)
|
||||
List,
|
||||
}
|
||||
|
||||
/// Alias management subcommands.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum AliasCmd {
|
||||
/// Add an alias (am alias add cc claude-code)
|
||||
Add {
|
||||
/// Short name of the alias
|
||||
name: String,
|
||||
/// Agent name (or another alias) it points to
|
||||
target: String,
|
||||
},
|
||||
/// Remove an alias
|
||||
Remove {
|
||||
/// Alias to remove
|
||||
name: String,
|
||||
},
|
||||
/// List every alias
|
||||
List,
|
||||
}
|
||||
|
||||
/// Configuration management subcommands.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ConfigCmd {
|
||||
@@ -214,4 +433,11 @@ pub enum ConfigCmd {
|
||||
/// YAML (or JSON) file defining agents to include
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Set one configuration key without an editor (config set settings.default_shell pwsh)
|
||||
Set {
|
||||
/// Dotted key path, e.g. settings.default_shell
|
||||
key: String,
|
||||
/// New value (booleans and integers keep their type)
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! alias: manage command aliases (cc -> claude-code) in the user config.
|
||||
|
||||
use super::*;
|
||||
use crate::cli::AliasCmd;
|
||||
use crate::output::print_json;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Ensure the user config file exists and return its path.
|
||||
fn user_config(app: &App) -> Result<PathBuf> {
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if !path.exists() {
|
||||
std::fs::write(&path, concat!("version: \"1.0\"\n", "agents: []\n"))
|
||||
.with_context(|| format!("cannot create {}", path.display()))?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Read the aliases map from the user config file.
|
||||
fn read_aliases(app: &App) -> Result<(PathBuf, serde_yaml::Mapping)> {
|
||||
let path = user_config(app)?;
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
|
||||
.with_context(|| format!("{} is not valid YAML", path.display()))?;
|
||||
let mapping = value
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
|
||||
let aliases = mapping
|
||||
.entry(serde_yaml::Value::String("aliases".to_string()))
|
||||
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()))
|
||||
.as_mapping()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok((path, aliases))
|
||||
}
|
||||
|
||||
fn write_aliases(app: &App, path: &std::path::Path, aliases: serde_yaml::Mapping) -> Result<()> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
|
||||
.with_context(|| format!("{} is not valid YAML", path.display()))?;
|
||||
if let Some(mapping) = value.as_mapping_mut() {
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("aliases".to_string()),
|
||||
serde_yaml::Value::Mapping(aliases),
|
||||
);
|
||||
}
|
||||
std::fs::write(path, serde_yaml::to_string(&value)?)
|
||||
.with_context(|| format!("cannot write {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn run(app: &App, sub: &AliasCmd) -> Result<i32> {
|
||||
match sub {
|
||||
AliasCmd::Add { name, target } => {
|
||||
// The target must be a known agent (or another alias).
|
||||
require_agent(app, target)?;
|
||||
let (path, mut aliases) = read_aliases(app)?;
|
||||
aliases.insert(
|
||||
serde_yaml::Value::String(name.clone()),
|
||||
serde_yaml::Value::String(target.clone()),
|
||||
);
|
||||
write_aliases(app, &path, aliases)?;
|
||||
app.log.success(&format!("alias '{name}' -> '{target}'"));
|
||||
Ok(0)
|
||||
}
|
||||
AliasCmd::Remove { name } => {
|
||||
let (path, mut aliases) = read_aliases(app)?;
|
||||
let removed = aliases
|
||||
.remove(&serde_yaml::Value::String(name.clone()))
|
||||
.is_some();
|
||||
if removed {
|
||||
write_aliases(app, &path, aliases)?;
|
||||
app.log.success(&format!("alias '{name}' removed"));
|
||||
} else {
|
||||
app.log.info(&format!("alias '{name}' does not exist"));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
AliasCmd::List => {
|
||||
if app.json() {
|
||||
print_json(&app.config.aliases);
|
||||
return Ok(0);
|
||||
}
|
||||
if app.config.aliases.is_empty() {
|
||||
app.log.info("no aliases defined — 'am alias add cc claude-code'");
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = crate::output::Table::new(vec!["ALIAS", "TARGET"]);
|
||||
for (k, v) in &app.config.aliases {
|
||||
table.row(vec![k.clone(), v.clone()]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::cli::ConfigCmd;
|
||||
use crate::config;
|
||||
use crate::output::print_json;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
@@ -94,9 +95,63 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
ConfigCmd::Add { file } => add_file(app, file),
|
||||
ConfigCmd::Set { key, value } => set_value(app, key, value),
|
||||
}
|
||||
}
|
||||
|
||||
/// config set: set one dotted key (e.g. settings.default_shell) in the user
|
||||
/// configuration file, then validate the result.
|
||||
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
|
||||
let path = user_config_path(app)?;
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
|
||||
.with_context(|| format!("{} is not valid YAML", path.display()))?;
|
||||
|
||||
let parts: Vec<&str> = key.split('.').collect();
|
||||
if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
|
||||
return Err(anyhow!("invalid key '{key}' — expected a dotted path like settings.default_shell"));
|
||||
}
|
||||
let parsed = parse_scalar(value);
|
||||
let mut node = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
let entry = node
|
||||
.entry(serde_yaml::Value::String(part.to_string()))
|
||||
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
|
||||
node = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
||||
}
|
||||
node.insert(
|
||||
serde_yaml::Value::String(parts.last().unwrap().to_string()),
|
||||
parsed,
|
||||
);
|
||||
std::fs::write(&path, serde_yaml::to_string(&root)?)
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
|
||||
// Validate the new configuration end to end.
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
|
||||
config::load(&cli).map_err(|e| {
|
||||
anyhow!("the new value makes the configuration invalid: {e:#}")
|
||||
})?;
|
||||
app.log.success(&format!("{key} = {value}"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Parse a scalar: booleans and integers keep their type, everything else
|
||||
/// stays a string.
|
||||
fn parse_scalar(value: &str) -> serde_yaml::Value {
|
||||
if let Ok(b) = value.parse::<bool>() {
|
||||
return serde_yaml::Value::Bool(b);
|
||||
}
|
||||
if let Ok(i) = value.parse::<i64>() {
|
||||
return serde_yaml::Value::Number(i.into());
|
||||
}
|
||||
serde_yaml::Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
|
||||
app.paths
|
||||
.config_dir
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use super::*;
|
||||
use crate::doctor::{self, Status};
|
||||
use crate::output::{print_json, status_style, Table};
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::{boxed_table, print_json, status_style};
|
||||
use crate::runner::SystemRunner;
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -22,21 +23,36 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
// The doctor report probes real tools, even under --dry-run.
|
||||
let runner = SystemRunner::new(false, false, &app.log);
|
||||
let report = doctor::run(app, &runner);
|
||||
if !app.dry_run() {
|
||||
app.emit(
|
||||
&Event::now(EventKind::Doctor)
|
||||
.with_exit_code(if report.ok() { 0 } else { 1 }),
|
||||
);
|
||||
}
|
||||
if app.json() {
|
||||
print_json(&report.checks);
|
||||
return Ok(if report.ok() { 0 } else { 1 });
|
||||
}
|
||||
let color = app.color();
|
||||
let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]);
|
||||
let theme = app.theme();
|
||||
let mut table_rows = Vec::new();
|
||||
for c in &report.checks {
|
||||
let status = match c.status {
|
||||
Status::Ok => status_style("ok", color),
|
||||
Status::Warn => status_style("warn", color),
|
||||
Status::Error => status_style("error", color),
|
||||
Status::Ok => status_style("ok", theme, color),
|
||||
Status::Warn => status_style("warn", theme, color),
|
||||
Status::Error => status_style("error", theme, color),
|
||||
};
|
||||
table.row(vec![c.name.clone(), status, c.detail.clone()]);
|
||||
table_rows.push(vec![c.name.clone(), status, c.detail.clone()]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
print!(
|
||||
"{}",
|
||||
boxed_table(
|
||||
vec!["CHECK", "STATUS", "DETAIL"],
|
||||
table_rows,
|
||||
theme,
|
||||
color,
|
||||
)
|
||||
);
|
||||
if report.problems() > 0 {
|
||||
app.log
|
||||
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
|
||||
|
||||
@@ -77,6 +77,7 @@ pub fn import_cmd(app: &App, file: &Path) -> Result<i32> {
|
||||
groups: Default::default(),
|
||||
include: vec![],
|
||||
agents: export.config.agents.clone(),
|
||||
projects: Default::default(),
|
||||
};
|
||||
std::fs::write(&dest, serde_yaml::to_string(&cfg)?)
|
||||
.with_context(|| format!("cannot write {}", dest.display()))?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! history: search and reuse the structured command history.
|
||||
|
||||
use super::*;
|
||||
use crate::history::{self, HistoryEntry};
|
||||
use crate::output::print_json;
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
|
||||
/// Re-run the Nth most recent entry: shell commands go through the
|
||||
/// active shell, am commands are parsed and dispatched like the REPL does.
|
||||
fn rerun_entry(app: &App, n: usize) -> Result<i32> {
|
||||
if n == 0 {
|
||||
return Err(anyhow!("invalid entry number 0 — am history numbers start at 1"));
|
||||
}
|
||||
let entries = history::read_all(app, n);
|
||||
let entry = entries.get(n - 1).cloned().ok_or_else(|| {
|
||||
anyhow!("history entry {n} not found — 'am history' lists them")
|
||||
})?;
|
||||
let question = format!("re-run: {} [y/N]", entry.cmd);
|
||||
if !app.confirm(&question)? {
|
||||
app.log.info("cancelled");
|
||||
return Ok(0);
|
||||
}
|
||||
let cmd = entry.cmd.strip_prefix('!').unwrap_or(&entry.cmd);
|
||||
if entry.kind == "shell" {
|
||||
let session = crate::shell::ShellSession::start(&app.config);
|
||||
return Ok(session.run(cmd)?);
|
||||
}
|
||||
let tokens = shell_words::split(cmd)
|
||||
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
|
||||
if tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let cli = crate::cli::Cli::try_parse_from(std::iter::once("am".to_string()).chain(tokens))
|
||||
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
|
||||
match &cli.command {
|
||||
Some(cmd) => execute_command(app, cmd),
|
||||
None => {
|
||||
app.log.info("nothing to re-run (no subcommand)");
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter history entries; returns them most recent first.
|
||||
pub fn filter(
|
||||
app: &App,
|
||||
kind: Option<&str>,
|
||||
cwd: Option<&str>,
|
||||
search: Option<&str>,
|
||||
failed: bool,
|
||||
session: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Vec<(String, HistoryEntry)> {
|
||||
let _ = session; // session filtering happens by reading one file directly
|
||||
let mut out: Vec<(String, HistoryEntry)> = Vec::new();
|
||||
let dir = history::dir(app);
|
||||
for f in history::files(&dir).iter().rev() {
|
||||
let sid = history::session_of_file(f).unwrap_or_default();
|
||||
if let Some(s) = session {
|
||||
if sid != s {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(f) else {
|
||||
continue;
|
||||
};
|
||||
let mut entries: Vec<HistoryEntry> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
|
||||
.collect();
|
||||
entries.reverse();
|
||||
for e in entries {
|
||||
if kind.map_or(true, |k| e.kind == k)
|
||||
&& cwd.map_or(true, |c| e.cwd.as_deref() == Some(c))
|
||||
&& search.map_or(true, |s| e.cmd.to_lowercase().contains(&s.to_lowercase()))
|
||||
&& (!failed || e.exit_code.is_some())
|
||||
{
|
||||
out.push((sid.clone(), e));
|
||||
if limit > 0 && out.len() >= limit {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
kind: Option<&str>,
|
||||
cwd: Option<&str>,
|
||||
search: Option<&str>,
|
||||
failed: bool,
|
||||
session: Option<&str>,
|
||||
limit: usize,
|
||||
rerun: Option<usize>,
|
||||
) -> Result<i32> {
|
||||
history::migrate(app)?;
|
||||
if let Some(n) = rerun {
|
||||
return rerun_entry(app, n);
|
||||
}
|
||||
let rows = filter(app, kind, cwd, search, failed, session, limit);
|
||||
if app.json() {
|
||||
let payload: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (sid, e))| {
|
||||
serde_json::json!({
|
||||
"n": rows.len() - i,
|
||||
"ts": e.ts,
|
||||
"cmd": e.cmd,
|
||||
"kind": e.kind,
|
||||
"cwd": e.cwd,
|
||||
"exit_code": e.exit_code,
|
||||
"duration_ms": e.duration_ms,
|
||||
"session": sid,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
print_json(&payload);
|
||||
return Ok(0);
|
||||
}
|
||||
for (i, (_sid, e)) in rows.iter().enumerate() {
|
||||
let n = rows.len() - i;
|
||||
let ts = e.ts.get(0..16).unwrap_or(&e.ts);
|
||||
let flag = match e.exit_code {
|
||||
Some(_) => "✗".to_string(),
|
||||
None => " ".to_string(),
|
||||
};
|
||||
println!("{n:>4} {flag} {ts} {}", e.cmd);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
app.log.info("no matching history — run 'am' and type some commands");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
+134
-48
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::output::print_json;
|
||||
use colored::Colorize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -101,51 +102,102 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
println!("{}", agent.title());
|
||||
if agent.name != agent.title() {
|
||||
println!(" slug: {}", agent.name);
|
||||
}
|
||||
if let Some(d) = &agent.description {
|
||||
println!(" description: {d}");
|
||||
}
|
||||
let color = app.color();
|
||||
let dim = |s: &str| -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let bold = |s: &str| -> String {
|
||||
if color {
|
||||
s.bold().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// --- Header -------------------------------------------------------------
|
||||
println!("{}", bold(agent.title()));
|
||||
let mut sub: Vec<String> = vec![agent.name.clone()];
|
||||
if let Some(c) = &agent.category {
|
||||
println!(" category: {c}");
|
||||
sub.push(c.clone());
|
||||
}
|
||||
println!(" {}", dim(&sub.join(" · ")));
|
||||
if let Some(d) = &agent.description {
|
||||
println!();
|
||||
println!(" {d}");
|
||||
}
|
||||
if let Some(w) = &agent.website {
|
||||
println!(" website: {w}");
|
||||
}
|
||||
if !agent.tags.is_empty() {
|
||||
println!(" tags: {}", agent.tags.join(", "));
|
||||
}
|
||||
if !agent.platforms.is_empty() {
|
||||
println!(" platforms: {}", agent.platforms.join(", "));
|
||||
println!();
|
||||
println!(" {} {}", dim("website"), w);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Status block
|
||||
let status = if running {
|
||||
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
|
||||
// --- Status --------------------------------------------------------------
|
||||
let external = external_path(agent);
|
||||
let (status_word, detail): (&str, String) = if running {
|
||||
let pid = entry.as_ref().and_then(|e| e.pid).unwrap_or(0);
|
||||
("running", format!(" (pid {pid})"))
|
||||
} else if entry.is_some() {
|
||||
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
|
||||
let v = entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.version.clone())
|
||||
.map(|v| format!(" — v{v}"))
|
||||
.unwrap_or_default();
|
||||
("installed", v)
|
||||
} else if !agent.installable {
|
||||
"not installable locally".to_string()
|
||||
} else if external_path(agent).is_some() {
|
||||
"found on PATH (external — not managed)".to_string()
|
||||
("not-installable", String::new())
|
||||
} else if external.is_some() {
|
||||
("external", " (found on PATH — not managed)".to_string())
|
||||
} else {
|
||||
"not installed".to_string()
|
||||
("not-installed", String::new())
|
||||
};
|
||||
println!(" status: {status}");
|
||||
let word = crate::output::status_style(status_word, app.theme(), color);
|
||||
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
|
||||
|
||||
// Version: recorded for managed installs, detected for external ones.
|
||||
let version_display: Option<String> = entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.version.clone())
|
||||
.or_else(|| external.as_ref().and_then(|p| crate::probe::detect_external_version(p)));
|
||||
if let Some(v) = version_display {
|
||||
println!(" {} {}", dim("● version"), v);
|
||||
}
|
||||
if let Some(t) = entry.as_ref().map(|e| e.installed_at.clone()) {
|
||||
println!(" {} {}", dim("● installed"), t);
|
||||
}
|
||||
if let Some(t) = entry.as_ref().and_then(|e| e.updated_at.clone()) {
|
||||
println!(" {} {}", dim("● updated"), t);
|
||||
}
|
||||
if let Some(p) = entry
|
||||
.as_ref()
|
||||
.map(|e| e.install_dir.clone())
|
||||
.or_else(|| external.clone().map(|p| p.display().to_string()))
|
||||
{
|
||||
println!(" {} {}", dim("● path"), p);
|
||||
}
|
||||
if let Some(e) = &entry {
|
||||
if let Some(bin) = e.bins.first() {
|
||||
println!(" {} {}", dim("● bin"), bin);
|
||||
}
|
||||
}
|
||||
|
||||
// --- How to run it -------------------------------------------------------
|
||||
if let Some(run) = &agent.run {
|
||||
let args = if agent.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", agent.args.join(" "))
|
||||
};
|
||||
println!(" run: {run}{args}");
|
||||
println!();
|
||||
println!(" {} {}", dim("▶ run"), format!("{run}{args}"));
|
||||
}
|
||||
if !agent.env.is_empty() {
|
||||
println!(
|
||||
" env: {}",
|
||||
" {} {}",
|
||||
dim("⚙ env"),
|
||||
agent
|
||||
.env
|
||||
.iter()
|
||||
@@ -155,38 +207,72 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Installation ---------------------------------------------------------
|
||||
if let Some(spec) = &agent.install {
|
||||
println!(" install methods:");
|
||||
println!();
|
||||
println!(" {}", bold("install"));
|
||||
for (i, m) in spec.all_methods().iter().enumerate() {
|
||||
let note = m
|
||||
.note
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default();
|
||||
println!(" {}) {}{}", i + 1, describe_method(m), note);
|
||||
println!(" {}) {}", i + 1, describe_method(m));
|
||||
if let Some(n) = &m.note {
|
||||
println!(" {}", dim(n));
|
||||
}
|
||||
}
|
||||
if !spec.post_install.is_empty() {
|
||||
println!(" post-install: {}", spec.post_install.join(" && "));
|
||||
println!(
|
||||
" {}",
|
||||
dim(&format!("post-install: {}", spec.post_install.join(" && ")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dependencies, checked live ------------------------------------------
|
||||
if !agent.dependencies.is_empty() {
|
||||
println!(" dependencies:");
|
||||
for d in &agent.dependencies {
|
||||
let min = d
|
||||
.min_version
|
||||
.as_deref()
|
||||
.map(|m| format!(" >= {m}"))
|
||||
.unwrap_or_default();
|
||||
let hint = d
|
||||
.install_hint
|
||||
.as_deref()
|
||||
.map(|h| format!(" — {h}"))
|
||||
.unwrap_or_default();
|
||||
println!(" - {}{}{}", d.name, min, hint);
|
||||
println!();
|
||||
println!(" {}", bold("dependencies"));
|
||||
let runner = crate::runner::SystemRunner::new(false, app.cli.verbose, &app.log);
|
||||
let checks = crate::deps::check_agent(agent, &runner);
|
||||
for c in &checks {
|
||||
let mark = if c.ok { "✓" } else { "✗" };
|
||||
let mark = if color {
|
||||
if c.ok {
|
||||
mark.green().to_string()
|
||||
} else {
|
||||
mark.red().to_string()
|
||||
}
|
||||
} else {
|
||||
mark.to_string()
|
||||
};
|
||||
let mut line = format!(" {mark} {}", c.name);
|
||||
if let Some(v) = &c.version {
|
||||
line.push_str(&format!(" {v}"));
|
||||
}
|
||||
if let Some(min) = &c.min {
|
||||
line.push_str(&format!(" (min {min})"));
|
||||
}
|
||||
if !c.ok {
|
||||
if let Some(hint) = agent
|
||||
.dependencies
|
||||
.iter()
|
||||
.find(|d| d.name == c.name)
|
||||
.and_then(|d| d.install_hint.clone())
|
||||
{
|
||||
line.push_str(&format!(" — {hint}"));
|
||||
} else if !c.detail.is_empty() && c.detail != "not found on PATH" {
|
||||
line.push_str(&format!(" — {}", c.detail));
|
||||
}
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tags and note ---------------------------------------------------------
|
||||
if !agent.tags.is_empty() {
|
||||
println!();
|
||||
println!(" {}", dim(&format!("tags {}", agent.tags.join(" · "))));
|
||||
}
|
||||
if let Some(n) = &agent.note {
|
||||
println!(" note: {n}");
|
||||
println!();
|
||||
println!(" {}", dim(&format!("note {n}")));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! init: generate a local agent-manager.yaml for the current directory,
|
||||
//! based on the detected technology stack.
|
||||
|
||||
use super::*;
|
||||
use crate::context;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Generate a local config file in dir; refuses to overwrite unless force.
|
||||
/// Returns the written path.
|
||||
pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
|
||||
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
|
||||
if target.exists() && !force {
|
||||
return Err(anyhow!(
|
||||
"{} already exists — use --force to overwrite it",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
let ctx = context::detect(dir);
|
||||
let agents = context::recommended_agents(&ctx.stack);
|
||||
let members = agents
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let stack = if ctx.stack.is_empty() {
|
||||
"aucune détectée".to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
};
|
||||
let content = format!(
|
||||
"# Généré par 'am init' le {date}
|
||||
# Pile détectée : {stack}
|
||||
version: \"1.0\"
|
||||
groups:
|
||||
dev: [{members}]
|
||||
agents: []
|
||||
",
|
||||
date = crate::installers::now_rfc3339(),
|
||||
);
|
||||
std::fs::write(&target, content)
|
||||
.with_context(|| format!("cannot write {}", target.display()))?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
pub fn run(app: &App, force: bool) -> Result<i32> {
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would generate {} in the current directory",
|
||||
crate::config::LOCAL_CONFIG_NAME
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let cwd = std::env::current_dir().context("cannot determine the current directory")?;
|
||||
let ctx = context::detect(&cwd);
|
||||
let path = generate(&cwd, force)?;
|
||||
app.log.success(&format!(
|
||||
"wrote {} (stack: {})",
|
||||
path.display(),
|
||||
if ctx.stack.is_empty() {
|
||||
"aucune détectée".to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
}
|
||||
));
|
||||
app.log.info(&format!(
|
||||
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! installer, and record the installation in the state database.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::SystemRunner;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
@@ -137,6 +138,16 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
|
||||
let entry = installers::make_entry(agent, method, &outcome, app);
|
||||
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
app.state.set(&entry)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Install)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![format!("method={}", method.kind)]),
|
||||
);
|
||||
crate::hooks::run_hooks(
|
||||
app,
|
||||
"on_install",
|
||||
&std::env::current_dir().unwrap_or_default(),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"installed {} (v{version}) in {}",
|
||||
agent.title(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! the whole catalog with --all.
|
||||
|
||||
use super::*;
|
||||
use crate::output::{print_json, status_style, Table};
|
||||
use crate::output::{boxed_table, print_json, status_style};
|
||||
use crate::probe::ExternalInfo;
|
||||
use crate::state::InstalledEntry;
|
||||
use serde::Serialize;
|
||||
@@ -25,7 +25,13 @@ struct AgentRow {
|
||||
}
|
||||
|
||||
/// run: installed agents only, unless all is set (whole catalog).
|
||||
pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
all: bool,
|
||||
category: Option<&str>,
|
||||
running: bool,
|
||||
sort: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
let entries = app.state.all().unwrap_or_default();
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
let mut rows: Vec<AgentRow> = Vec::new();
|
||||
@@ -43,24 +49,42 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
|
||||
if !all {
|
||||
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
|
||||
}
|
||||
rows.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if running {
|
||||
rows.retain(|r| r.status == "running");
|
||||
}
|
||||
match sort {
|
||||
Some("version") => rows.sort_by(|a, b| b.version.cmp(&a.version)),
|
||||
Some("status") => rows.sort_by(|a, b| a.status.cmp(&b.status)),
|
||||
Some(other) => bail!("invalid sort key '{other}' — expected name, version or status"),
|
||||
None => rows.sort_by(|a, b| a.name.cmp(&b.name)),
|
||||
}
|
||||
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let color = app.color();
|
||||
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
|
||||
let theme = app.theme();
|
||||
let mut table_rows = Vec::new();
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
table_rows.push(vec![
|
||||
r.display_name.clone(),
|
||||
r.name.clone(),
|
||||
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
||||
status_style(&r.status, color),
|
||||
status_style(&r.status, theme, color),
|
||||
r.source.clone(),
|
||||
r.path.clone().unwrap_or_else(|| "—".to_string()),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
print!(
|
||||
"{}",
|
||||
boxed_table(
|
||||
vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"],
|
||||
table_rows,
|
||||
theme,
|
||||
color,
|
||||
)
|
||||
);
|
||||
if rows.is_empty() {
|
||||
app.log.info(if all {
|
||||
"no agents found"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! log: a filtered view of the event journal — everything am did.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{self, Event};
|
||||
use crate::output::print_json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Filter journal events: agent, kind list, since date, and a max count.
|
||||
/// Returns events in reverse chronological order (most recent first).
|
||||
pub fn filter_events(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
kinds: Option<&str>,
|
||||
since: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Event>> {
|
||||
let mut events = events::read_events(&app.events_dir(), 0);
|
||||
if let Some(a) = agent {
|
||||
events.retain(|e| e.agent.as_deref() == Some(a));
|
||||
}
|
||||
if let Some(list) = kinds {
|
||||
let wanted: Vec<&str> = list.split(',').map(|s| s.trim()).collect();
|
||||
events.retain(|e| wanted.iter().any(|w| e.kind.as_str() == *w));
|
||||
}
|
||||
if let Some(s) = since {
|
||||
let cut = parse_since(s)?;
|
||||
events.retain(|e| {
|
||||
chrono::DateTime::parse_from_rfc3339(&e.ts)
|
||||
.map(|t| t >= cut)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
events.reverse(); // most recent first
|
||||
events.truncate(limit);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Live view: print matching events as they arrive (issue #38).
|
||||
fn follow_loop(app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>) -> Result<i32> {
|
||||
app.log.info("following the journal (Ctrl-C to stop)");
|
||||
let mut seen: usize = 0;
|
||||
loop {
|
||||
let events = filter_events(app, agent, kinds, since, 200)?;
|
||||
let mut rendered = String::new();
|
||||
for e in events.iter().rev() {
|
||||
rendered.push_str(&e.ts);
|
||||
rendered.push(' ');
|
||||
rendered.push_str(e.kind.as_str());
|
||||
if let Some(a) = &e.agent {
|
||||
rendered.push(' ');
|
||||
rendered.push_str(a);
|
||||
}
|
||||
rendered.push('\n');
|
||||
}
|
||||
let bytes = rendered.as_bytes();
|
||||
if bytes.len() > seen {
|
||||
print!("{}", text_after(&rendered, seen));
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
seen = bytes.len();
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
|
||||
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return Ok(t);
|
||||
}
|
||||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map_err(|_| anyhow!("invalid date '{s}' — expected YYYY-MM-DD or an RFC 3339 timestamp"))?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|d| chrono::DateTime::from_naive_utc_and_offset(d, chrono::FixedOffset::east_opt(0).unwrap()))
|
||||
.ok_or_else(|| anyhow!("invalid date '{s}'"))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogRow {
|
||||
ts: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
pid: Option<u32>,
|
||||
exit_code: Option<i32>,
|
||||
duration_s: Option<u64>,
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Event> for LogRow {
|
||||
fn from(e: &Event) -> Self {
|
||||
LogRow {
|
||||
ts: e.ts.clone(),
|
||||
kind: e.kind.as_str().to_string(),
|
||||
agent: e.agent.clone(),
|
||||
pid: e.pid,
|
||||
exit_code: e.exit_code,
|
||||
duration_s: e.duration_s,
|
||||
session: e.session.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chars of text after byte offset seen (UTF-8 safe).
|
||||
pub fn text_after(text: &str, seen: usize) -> String {
|
||||
text.char_indices()
|
||||
.filter(|(i, _)| *i >= seen)
|
||||
.map(|(_, c)| c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
kinds: Option<&str>,
|
||||
since: Option<&str>,
|
||||
limit: usize,
|
||||
follow: bool,
|
||||
) -> Result<i32> {
|
||||
if follow {
|
||||
return follow_loop(app, agent, kinds, since);
|
||||
}
|
||||
let events = filter_events(app, agent, kinds, since, limit)?;
|
||||
if app.json() {
|
||||
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
for e in &events {
|
||||
let mut parts = vec![e.ts.clone(), e.kind.as_str().to_string()];
|
||||
if let Some(a) = &e.agent {
|
||||
parts.push(a.clone());
|
||||
}
|
||||
if let Some(p) = e.pid {
|
||||
parts.push(format!("pid={p}"));
|
||||
}
|
||||
if let Some(c) = e.exit_code {
|
||||
parts.push(format!("exit={c}"));
|
||||
}
|
||||
if let Some(d) = e.duration_s {
|
||||
parts.push(format!("dur={d}s"));
|
||||
}
|
||||
println!("{}", parts.join(" "));
|
||||
}
|
||||
if events.is_empty() {
|
||||
app.log.info("no matching events in the journal");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_since_accepts_date_and_timestamp() {
|
||||
let d = parse_since("2026-08-01").unwrap();
|
||||
assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-08-01");
|
||||
assert!(parse_since("2026-08-01T00:00:00Z").is_ok());
|
||||
assert!(parse_since("hier").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! logs: tail the log file of an agent (--follow for a live view).
|
||||
|
||||
use super::*;
|
||||
use crate::output::print_json;
|
||||
use anyhow::{bail, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve the log path of an agent (validates the agent first).
|
||||
pub fn log_path(app: &App, agent_name: &str) -> Result<PathBuf> {
|
||||
let agent = require_agent(app, agent_name)?;
|
||||
Ok(crate::process::agent_log_path(app, &agent.name))
|
||||
}
|
||||
|
||||
pub fn run(app: &App, agent: &str, lines: usize, follow: bool) -> Result<i32> {
|
||||
let path = log_path(app, agent)?;
|
||||
if !path.exists() {
|
||||
bail!(
|
||||
"no log yet for '{}' — start it with 'am start {} --background'",
|
||||
agent,
|
||||
agent
|
||||
);
|
||||
}
|
||||
if app.json() {
|
||||
let tail = crate::process::last_lines(&path, lines);
|
||||
print_json(&serde_json::json!({
|
||||
"agent": agent,
|
||||
"log": path.display().to_string(),
|
||||
"tail": tail,
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
if !follow {
|
||||
print!("{}", crate::process::last_lines(&path, lines));
|
||||
return Ok(0);
|
||||
}
|
||||
// Live follow: print the initial tail, then new bytes as they arrive.
|
||||
let mut seen: usize = std::fs::metadata(&path).map(|m| m.len() as usize).unwrap_or(0);
|
||||
loop {
|
||||
if let Ok(text) = std::fs::read_to_string(&path) {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() > seen {
|
||||
// Slicer sans risque de couper un caractere UTF-8.
|
||||
let tail: String = text
|
||||
.char_indices()
|
||||
.filter(|(i, _)| *i >= seen)
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
print!("{tail}");
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
seen = bytes.len();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
+144
-1
@@ -1,18 +1,33 @@
|
||||
//! Command implementations and dispatch.
|
||||
|
||||
pub mod alias_cmd;
|
||||
pub mod completion_cmd;
|
||||
pub mod config_cmd;
|
||||
pub mod doctor_cmd;
|
||||
pub mod export_import;
|
||||
pub mod help_cmd;
|
||||
pub mod history_cmd;
|
||||
pub mod info_cmd;
|
||||
pub mod init_cmd;
|
||||
pub mod install_cmd;
|
||||
pub mod list_cmd;
|
||||
pub mod log_cmd;
|
||||
pub mod logs_cmd;
|
||||
pub mod open_cmd;
|
||||
pub mod projects_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod secret_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod sessions_cmd;
|
||||
pub mod stats_cmd;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod timeline_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
pub mod update_cmd;
|
||||
pub mod watch_cmd;
|
||||
pub mod version_cmd;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::Command;
|
||||
@@ -32,7 +47,26 @@ pub fn execute(app: &App) -> Result<i32> {
|
||||
/// Execute one parsed command (also used by the interactive shell).
|
||||
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
match cmd {
|
||||
Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()),
|
||||
Command::List {
|
||||
all,
|
||||
category,
|
||||
running,
|
||||
sort,
|
||||
} => list_cmd::run(app, *all, category.as_deref(), *running, sort.as_deref()),
|
||||
Command::Logs {
|
||||
agent,
|
||||
lines,
|
||||
follow,
|
||||
} => logs_cmd::run(app, agent, *lines, *follow),
|
||||
Command::Open { agent } => open_cmd::run(app, agent),
|
||||
Command::Watch {
|
||||
agent,
|
||||
restart,
|
||||
notify,
|
||||
interval,
|
||||
} => watch_cmd::run(app, agent, *restart, *notify, *interval),
|
||||
Command::Alias(sub) => alias_cmd::run(app, sub),
|
||||
Command::Secret(sub) => secret_cmd::run(app, sub),
|
||||
Command::Start(a) => run_cmd::start(app, a),
|
||||
Command::Stop {
|
||||
agent,
|
||||
@@ -45,6 +79,41 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
timeout,
|
||||
} => run_cmd::restart(app, start, *force, *timeout),
|
||||
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
|
||||
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
|
||||
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
|
||||
Command::Report {
|
||||
last_week,
|
||||
last_month,
|
||||
output,
|
||||
} => stats_cmd::run_report(app, *last_week, *last_month, output.as_deref()),
|
||||
Command::Log {
|
||||
agent,
|
||||
kind,
|
||||
since,
|
||||
limit,
|
||||
follow,
|
||||
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit, *follow),
|
||||
Command::Projects { name } => projects_cmd::run(app, name.as_deref()),
|
||||
Command::Timeline {
|
||||
agent,
|
||||
project,
|
||||
since,
|
||||
limit,
|
||||
} => timeline_cmd::run(app, agent.as_deref(), project.as_deref(), since.as_deref(), *limit),
|
||||
Command::Sessions {
|
||||
agent,
|
||||
project,
|
||||
status,
|
||||
show,
|
||||
resume,
|
||||
} => sessions_cmd::run(
|
||||
app,
|
||||
agent.as_deref(),
|
||||
project.as_deref(),
|
||||
status.as_deref(),
|
||||
show.as_deref(),
|
||||
resume.as_deref(),
|
||||
),
|
||||
Command::Install {
|
||||
agent,
|
||||
method,
|
||||
@@ -53,12 +122,34 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
|
||||
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
|
||||
Command::History {
|
||||
kind,
|
||||
cwd,
|
||||
search,
|
||||
failed,
|
||||
session,
|
||||
limit,
|
||||
rerun,
|
||||
} => history_cmd::run(
|
||||
app,
|
||||
kind.as_deref(),
|
||||
cwd.as_deref(),
|
||||
search.as_deref(),
|
||||
*failed,
|
||||
session.as_deref(),
|
||||
*limit,
|
||||
*rerun,
|
||||
),
|
||||
Command::Info { agent } => info_cmd::run(app, agent),
|
||||
Command::Init { force } => init_cmd::run(app, *force),
|
||||
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||
Command::Version => version_cmd::run(app),
|
||||
Command::Config(sub) => config_cmd::run(app, sub),
|
||||
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
||||
Command::Completion { shell } => completion_cmd::run(*shell),
|
||||
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
|
||||
Command::SelfUninstall => self_uninstall::run(app),
|
||||
Command::Export { output } => export_import::export_cmd(app, output),
|
||||
Command::Import { file } => export_import::import_cmd(app, file),
|
||||
}
|
||||
@@ -113,9 +204,31 @@ pub fn resolve_exec(
|
||||
args.extend(extra_args.iter().cloned());
|
||||
let mut env = agent.env.clone();
|
||||
env.extend(extra_env.clone());
|
||||
let warnings =
|
||||
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
|
||||
for w in warnings {
|
||||
app.log.warn(&w);
|
||||
}
|
||||
let (program, args) = route_cmd_script(program, args);
|
||||
Ok(ResolvedExec { program, args, env })
|
||||
}
|
||||
|
||||
/// On Windows, .cmd/.bat shims (git installs without a build step) must be
|
||||
/// executed through cmd.exe — spawning them directly fails.
|
||||
pub fn route_cmd_script(program: String, args: Vec<String>) -> (String, Vec<String>) {
|
||||
let lower = program.to_lowercase();
|
||||
if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) {
|
||||
let shell = std::env::var_os("COMSPEC")
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "cmd.exe".to_string());
|
||||
let mut full = vec!["/C".to_string(), program];
|
||||
full.extend(args);
|
||||
(shell, full)
|
||||
} else {
|
||||
(program, args)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse --env KEY=VALUE entries.
|
||||
pub fn parse_env_list(items: &[String]) -> Result<BTreeMap<String, String>> {
|
||||
let mut map = BTreeMap::new();
|
||||
@@ -211,3 +324,33 @@ pub fn select_method<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes_cmd_scripts_through_comspec() {
|
||||
let (program, args) = route_cmd_script(
|
||||
"C:/x/agent.cmd".to_string(),
|
||||
vec!["--flag".to_string()],
|
||||
);
|
||||
let lower = program.to_lowercase();
|
||||
if cfg!(windows) {
|
||||
assert!(lower.ends_with("cmd.exe"), "{program}");
|
||||
assert_eq!(args[0], "/C");
|
||||
assert_eq!(args[1], "C:/x/agent.cmd");
|
||||
assert_eq!(args[2], "--flag");
|
||||
} else {
|
||||
assert_eq!(program, "C:/x/agent.cmd");
|
||||
assert_eq!(args, vec!["--flag".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_regular_programs_untouched() {
|
||||
let (program, args) = route_cmd_script("node".to_string(), vec!["-v".to_string()]);
|
||||
assert_eq!(program, "node");
|
||||
assert_eq!(args, vec!["-v".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! open: open an agent's installation directory in the file manager.
|
||||
|
||||
use super::*;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve the directory to open: the managed install dir, or the parent of
|
||||
/// the detected external binary.
|
||||
pub fn resolve_target(app: &App, agent_name: &str) -> Result<PathBuf> {
|
||||
let agent = require_agent(app, agent_name)?;
|
||||
if let Some(entry) = app.state.get(&agent.name).ok().flatten() {
|
||||
return Ok(PathBuf::from(&entry.install_dir));
|
||||
}
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
if let Some(bin) = probe.path_of(&agent.name) {
|
||||
return Ok(bin
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| bin.clone()));
|
||||
}
|
||||
bail!("agent '{}' is not installed — run 'am install {}' first", agent_name, agent_name);
|
||||
}
|
||||
|
||||
pub fn run(app: &App, agent: &str) -> Result<i32> {
|
||||
let target = resolve_target(app, agent)?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would open {}", target.display()));
|
||||
return Ok(0);
|
||||
}
|
||||
if !target.exists() {
|
||||
bail!("directory {} does not exist", target.display());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
let opened = std::process::Command::new("explorer").arg(&target).status();
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = std::process::Command::new("open").arg(&target).status();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let opened = std::process::Command::new("xdg-open").arg(&target).status();
|
||||
opened
|
||||
.with_context(|| format!("cannot open {}", target.display()))?;
|
||||
Ok(0)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! projects: which agent works on which project.
|
||||
|
||||
use super::*;
|
||||
use crate::output::{print_json, Table};
|
||||
use crate::projects::{self, ProjectSummary};
|
||||
|
||||
pub fn run(app: &App, name: Option<&str>) -> Result<i32> {
|
||||
let summaries = projects::aggregate(app);
|
||||
if let Some(n) = name {
|
||||
return show_one(app, n, &summaries);
|
||||
}
|
||||
if app.json() {
|
||||
print_json(&summaries);
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = Table::new(vec!["PROJECT", "AGENTS", "SESSIONS", "DURATION", "LAST"]);
|
||||
for s in &summaries {
|
||||
table.row(vec![
|
||||
short_path(&s.root),
|
||||
s.dominant
|
||||
.clone()
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
s.sessions.to_string(),
|
||||
format_duration(s.total_duration_s),
|
||||
s.last_activity
|
||||
.as_deref()
|
||||
.map(|t| t[..t.len().min(10)].to_string())
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
if summaries.is_empty() {
|
||||
app.log.info("no project activity recorded yet — run or start an agent inside a git repository");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn show_one(app: &App, name: &str, summaries: &[ProjectSummary]) -> Result<i32> {
|
||||
let s = summaries
|
||||
.iter()
|
||||
.find(|s| {
|
||||
s.root == name
|
||||
|| s.root.ends_with(&format!("/{name}"))
|
||||
|| s.root.ends_with(&format!("\\{name}"))
|
||||
})
|
||||
.ok_or_else(|| anyhow!("unknown project '{name}' — 'am projects' lists them"))?;
|
||||
if app.json() {
|
||||
print_json(s);
|
||||
return Ok(0);
|
||||
}
|
||||
println!("project {}", s.root);
|
||||
println!(" agents: {}", s.agents.join(", "));
|
||||
println!(
|
||||
" dominant: {}",
|
||||
s.dominant.as_deref().unwrap_or("—")
|
||||
);
|
||||
println!(" sessions: {}", s.sessions);
|
||||
println!(" launches: {}", s.launches);
|
||||
println!(" duration: {}", format_duration(s.total_duration_s));
|
||||
println!(
|
||||
" last: {}",
|
||||
s.last_activity.as_deref().unwrap_or("—")
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Keep the tail of a long path.
|
||||
fn short_path(p: &str) -> String {
|
||||
let s = p.trim_end_matches(['/', '\\']);
|
||||
let name = s.rsplit(['/', '\\']).next().unwrap_or(s);
|
||||
let mut out = format!("…/{name}");
|
||||
let full = p.chars().count();
|
||||
let shown = out.chars().count();
|
||||
if shown >= full || full <= 30 {
|
||||
return p.to_string();
|
||||
}
|
||||
out.truncate(30);
|
||||
out
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
if secs == 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
+130
-14
@@ -2,28 +2,50 @@
|
||||
|
||||
use super::*;
|
||||
use crate::cli::StartArgs;
|
||||
use crate::events::{self, Event, EventKind};
|
||||
use crate::process;
|
||||
use crate::sessions;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Resolve the start target: the explicit agent/alias/group when given,
|
||||
/// otherwise the default agent of the project containing cwd (issue #30).
|
||||
pub fn resolve_start_target(app: &App, cwd: &std::path::Path, agent: Option<&str>) -> Result<String> {
|
||||
if let Some(a) = agent.filter(|s| !s.is_empty()) {
|
||||
return Ok(a.to_string());
|
||||
}
|
||||
crate::projects::default_agent_for_dir(app, cwd).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no agent given — set default_agent in this project's profile ('am init' does it) or pass an agent name"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
|
||||
start_with_cwd(app, opts, None)
|
||||
}
|
||||
|
||||
/// Start with an explicit working directory (used by sessions resume).
|
||||
pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>) -> Result<i32> {
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
let target = resolve_start_target(app, ¤t, opts.agent.as_deref())?;
|
||||
let extra_env = parse_env_list(&opts.env)?;
|
||||
let extra_args = parse_extra_args(&opts.args);
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||
let members = app.catalog.group_members(group);
|
||||
if members.is_empty() {
|
||||
bail!("unknown group '{group}'");
|
||||
}
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let agent = require_agent(app, &opts.agent)?;
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
|
||||
let agent = require_agent(app, &target)?;
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd)
|
||||
}
|
||||
|
||||
fn start_one(
|
||||
@@ -33,12 +55,16 @@ fn start_one(
|
||||
extra_env: &BTreeMap<String, String>,
|
||||
notify: bool,
|
||||
background: bool,
|
||||
cwd: Option<&std::path::Path>,
|
||||
) -> Result<i32> {
|
||||
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
||||
let bin = PathBuf::from(&exec.program);
|
||||
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
|
||||
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t));
|
||||
if background {
|
||||
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
|
||||
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?;
|
||||
if app.dry_run() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -51,7 +77,30 @@ fn start_one(
|
||||
agent.name
|
||||
));
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Start)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_pid(pid)
|
||||
.with_session(crate::repl::session_id())
|
||||
.with_cwd(cwd_string())
|
||||
.with_project(ctx.root.clone())
|
||||
.with_args(exec.args.clone())
|
||||
.with_env_keys(events::env_keys(&exec.env)),
|
||||
);
|
||||
if is_managed {
|
||||
let _ = app.state.touch(&agent.name);
|
||||
}
|
||||
let log = process::agent_log_path(app, &agent.name);
|
||||
let _ = sessions::start_agent(
|
||||
app,
|
||||
&agent.name,
|
||||
pid,
|
||||
cwd_string(),
|
||||
ctx.root.clone(),
|
||||
exec.args.clone(),
|
||||
events::env_keys(&exec.env),
|
||||
Some(log.display().to_string()),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"{} started (pid {pid}); log: {}",
|
||||
agent.title(),
|
||||
@@ -70,15 +119,31 @@ fn start_one(
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
let status = Command::new(&prog)
|
||||
.args(&full_args)
|
||||
.envs(&exec.env)
|
||||
let mut cmd = Command::new(&prog);
|
||||
cmd.args(&full_args).envs(&exec.env);
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let status = cmd
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
let code = status.code().unwrap_or(1);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_cwd(cwd_string())
|
||||
.with_project(ctx.root.clone())
|
||||
.with_args(exec.args.clone())
|
||||
.with_env_keys(events::env_keys(&exec.env))
|
||||
.with_exit_code(code)
|
||||
.with_duration(started.elapsed().as_secs()),
|
||||
);
|
||||
let _ = app.state.touch(&agent.name);
|
||||
Ok(code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +188,34 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
|
||||
"stale PID {pid} for '{}' (process no longer running); cleaning up",
|
||||
agent.name
|
||||
));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Stop)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_pid(pid)
|
||||
.with_reason("stale"),
|
||||
);
|
||||
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
|
||||
app.state.update_pid(&agent.name, None, None)?;
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_stop", ¤t);
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
|
||||
let stopped = process::stop_pid(pid, force, timeout);
|
||||
let duration = seconds_since(entry.started_at.as_deref());
|
||||
let mut ev = Event::now(EventKind::Stop)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_pid(pid)
|
||||
.with_reason(if force { "force" } else { "signal" })
|
||||
.with_exit_code(if stopped { 0 } else { 1 });
|
||||
if let Some(d) = duration {
|
||||
ev = ev.with_duration(d);
|
||||
}
|
||||
app.emit(&ev);
|
||||
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
|
||||
app.state.update_pid(&agent.name, None, None)?;
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_stop", ¤t);
|
||||
if stopped {
|
||||
app.log.success(&format!("{} stopped", agent.title()));
|
||||
Ok(0)
|
||||
@@ -140,7 +227,9 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
|
||||
|
||||
pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -> Result<i32> {
|
||||
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let target = resolve_start_target(app, &cwd, opts.agent.as_deref())?;
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||
let members = app.catalog.group_members(group);
|
||||
if members.is_empty() {
|
||||
bail!("unknown group '{group}'");
|
||||
@@ -151,15 +240,15 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
||||
let extra_env = parse_env_list(&opts.env)?;
|
||||
let extra_args = parse_extra_args(&opts.args);
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?;
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let agent = require_agent(app, &opts.agent)?;
|
||||
let agent = require_agent(app, &target)?;
|
||||
stop_one(app, agent, force, timeout)?;
|
||||
let extra_env = parse_env_list(&opts.env)?;
|
||||
let extra_args = parse_extra_args(&opts.args);
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None)
|
||||
}
|
||||
|
||||
/// run: execute the agent command directly, no process management.
|
||||
@@ -175,6 +264,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
@@ -183,5 +273,31 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
||||
.envs(&exec.env)
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
let code = status.code().unwrap_or(1);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_cwd(cwd_string())
|
||||
.with_args(exec.args.clone())
|
||||
.with_env_keys(events::env_keys(&exec.env))
|
||||
.with_exit_code(code)
|
||||
.with_duration(started.elapsed().as_secs()),
|
||||
);
|
||||
let _ = app.state.touch(&agent.name);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Current directory as a string (None when unavailable).
|
||||
fn cwd_string() -> Option<String> {
|
||||
std::env::current_dir().ok().map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
|
||||
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
||||
let now = chrono::Utc::now();
|
||||
let secs = now
|
||||
.signed_duration_since(t.with_timezone(&chrono::Utc))
|
||||
.num_seconds();
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! search: keyword search over the catalog.
|
||||
|
||||
use super::*;
|
||||
use crate::output::{print_json, Table};
|
||||
use crate::output::{boxed_table, print_json};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -33,20 +33,36 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]);
|
||||
let mut table_rows = Vec::new();
|
||||
for a in hits {
|
||||
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
|
||||
table.row(vec![
|
||||
table_rows.push(vec![
|
||||
a.title().to_string(),
|
||||
a.category.clone().unwrap_or_else(|| "—".to_string()),
|
||||
if a.installable { "yes" } else { "no" }.to_string(),
|
||||
desc,
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
print!(
|
||||
"{}",
|
||||
boxed_table(
|
||||
vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"],
|
||||
table_rows,
|
||||
app.theme(),
|
||||
app.color(),
|
||||
)
|
||||
);
|
||||
if app.catalog.search(keyword, category).is_empty() {
|
||||
app.log
|
||||
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
||||
let suggestions = app.catalog.suggest(keyword);
|
||||
if suggestions.is_empty() {
|
||||
app.log
|
||||
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
||||
} else {
|
||||
app.log.info(&format!(
|
||||
"no agent matches '{keyword}' — vouliez-vous dire : {} ?",
|
||||
suggestions.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! secret: manage secrets in the OS keyring (values never shown or logged).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::SecretCmd;
|
||||
use crate::output::print_json;
|
||||
use crate::secrets::{self, SecretStore};
|
||||
|
||||
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
|
||||
match sub {
|
||||
SecretCmd::Set { name, agent, value } => {
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would store secret {name} for {agent} in the OS keyring"));
|
||||
return Ok(0);
|
||||
}
|
||||
let key = secrets::key_for(agent, name);
|
||||
secrets::store().set(&key, value)?;
|
||||
app.log.success(&format!("secret {name} stored for {agent}"));
|
||||
app.log.info("use it with --env NAME=@secret (injected at start/run)");
|
||||
Ok(0)
|
||||
}
|
||||
SecretCmd::Unset { name, agent } => {
|
||||
let key = secrets::key_for(agent, name);
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove secret {key}"));
|
||||
return Ok(0);
|
||||
}
|
||||
let removed = secrets::store().remove(&key)?;
|
||||
if removed {
|
||||
app.log.success(&format!("secret {name} removed for {agent}"));
|
||||
} else {
|
||||
app.log.info(&format!("secret {name} does not exist for {agent}"));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
SecretCmd::List => {
|
||||
let all = secrets::store().list().unwrap_or_default();
|
||||
if app.json() {
|
||||
print_json(&all);
|
||||
return Ok(0);
|
||||
}
|
||||
if all.is_empty() {
|
||||
app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ...");
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = crate::output::Table::new(vec!["SECRET"]);
|
||||
for k in all {
|
||||
if k != "__index__" {
|
||||
table.row(vec![k]);
|
||||
}
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! self-uninstall: remove agent-manager and everything it created from this
|
||||
//! machine — background agents are stopped first, then the data, state and
|
||||
//! configuration directories are deleted, then the executable itself.
|
||||
|
||||
use crate::app::{self, App};
|
||||
use anyhow::{bail, Result};
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
use colored::Colorize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(windows)]
|
||||
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
|
||||
#[cfg(windows)]
|
||||
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
|
||||
#[cfg(windows)]
|
||||
const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFF_FFFF;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[link(name = "Kernel32")]
|
||||
extern "system" {
|
||||
fn MoveFileExW(
|
||||
lpExistingFileName: *const u16,
|
||||
lpNewFileName: *const u16,
|
||||
dwFlags: u32,
|
||||
) -> i32;
|
||||
fn GetFileAttributesW(lpFileName: *const u16) -> u32;
|
||||
fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32;
|
||||
}
|
||||
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
// 1. Stop every managed background agent.
|
||||
let state = app.state.load()?;
|
||||
for (name, entry) in &state.installed {
|
||||
if let Some(pid) = entry.pid {
|
||||
if crate::process::is_running(pid) {
|
||||
app.log.info(&format!("stopping agent '{name}' (pid {pid})"));
|
||||
crate::process::stop_pid(pid, false, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect the directories agent-manager owns.
|
||||
let data_root = app::data_dir();
|
||||
let state_root = app::state_dir();
|
||||
let mut roots: Vec<(&str, PathBuf)> = Vec::new();
|
||||
if data_root.is_dir() {
|
||||
roots.push(("data", data_root.clone()));
|
||||
}
|
||||
if state_root != data_root && state_root.is_dir() {
|
||||
roots.push(("state", state_root));
|
||||
}
|
||||
if let Some(dir) = crate::config::user_config_dir() {
|
||||
if dir.is_dir() {
|
||||
roots.push(("config", dir));
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok();
|
||||
let mut renamed: Option<PathBuf> = None;
|
||||
|
||||
if roots.is_empty() {
|
||||
app.log
|
||||
.info("nothing to remove — agent-manager left no traces on this machine");
|
||||
} else {
|
||||
app.log.info("agent-manager will remove:");
|
||||
for (label, path) in &roots {
|
||||
println!(" {label}: {}", path.display());
|
||||
}
|
||||
if !app.confirm("Remove everything listed above?")? {
|
||||
app.log.info("cancelled — nothing was removed");
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry("nothing removed (dry run)");
|
||||
return Ok(0);
|
||||
}
|
||||
// Windows allows renaming a running executable (but not deleting
|
||||
// it): do it up-front so the original binary disappears immediately,
|
||||
// even if the deferred deletion has to wait for this process to exit.
|
||||
renamed = exe.as_deref().and_then(rename_for_deletion);
|
||||
if let Some(new) = &renamed {
|
||||
if let Some(old) = &exe {
|
||||
println!(
|
||||
"{} {} -> {}",
|
||||
"renamed:".cyan(),
|
||||
old.display(),
|
||||
new.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Remove everything first; any message logged *after* deleting the
|
||||
// state directory would recreate the log folder, so the removal
|
||||
// itself is silent and the report below goes to plain stdout.
|
||||
// The removal is tolerant: read-only attributes are cleared and files
|
||||
// locked by other processes (for example the running am.exe when it
|
||||
// lives inside the data directory) are scheduled for deletion at the
|
||||
// next reboot instead of failing the whole uninstall.
|
||||
let mut locked: Vec<PathBuf> = Vec::new();
|
||||
for (_, path) in &roots {
|
||||
remove_tree(path, &mut locked);
|
||||
}
|
||||
for (_, path) in &roots {
|
||||
if !path.exists() {
|
||||
println!("{} {}", "removed:".green(), path.display());
|
||||
}
|
||||
}
|
||||
if !locked.is_empty() {
|
||||
let scheduled = schedule_locked(&locked);
|
||||
if !scheduled.is_empty() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
"locked:".yellow(),
|
||||
scheduled.len()
|
||||
);
|
||||
}
|
||||
if scheduled.len() != locked.len() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
"deferred:".cyan(),
|
||||
locked.len() - scheduled.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the executable itself, then sweep the directories left
|
||||
// empty behind it.
|
||||
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||
match &exe {
|
||||
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
|
||||
None => app.log
|
||||
.warn("cannot locate the agent-manager executable — delete it manually"),
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Recursively remove a tree, tolerating locked or read-only entries.
|
||||
/// Paths that could not be removed are collected into `locked` so the
|
||||
/// caller can schedule their deletion at the next reboot.
|
||||
fn remove_tree(path: &Path, locked: &mut Vec<PathBuf>) {
|
||||
let meta = match std::fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
if meta.is_dir() {
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
remove_tree(&entry.path(), locked);
|
||||
}
|
||||
}
|
||||
clear_readonly(path);
|
||||
if std::fs::remove_dir(path).is_err() {
|
||||
locked.push(path.to_path_buf());
|
||||
}
|
||||
} else {
|
||||
clear_readonly(path);
|
||||
if std::fs::remove_file(path).is_err() {
|
||||
locked.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the read-only attribute (a common cause of "Access denied" on
|
||||
/// Windows, e.g. files extracted by Expand-Archive).
|
||||
#[cfg(windows)]
|
||||
fn clear_readonly(path: &Path) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let wide: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
unsafe {
|
||||
let attrs = GetFileAttributesW(wide.as_ptr());
|
||||
if attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY) != 0 {
|
||||
SetFileAttributesW(wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn clear_readonly(_path: &Path) {}
|
||||
|
||||
/// Schedule every locked path for deletion at the next reboot (Windows).
|
||||
/// Returns the paths that could be scheduled.
|
||||
#[cfg(windows)]
|
||||
fn schedule_locked(locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut scheduled = Vec::new();
|
||||
for path in locked {
|
||||
let wide: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let ok = unsafe {
|
||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||
};
|
||||
if ok {
|
||||
scheduled.push(path.clone());
|
||||
}
|
||||
}
|
||||
scheduled
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Delete the running executable. On Unix the file can be unlinked while it
|
||||
/// runs; on Windows a hidden cmd re-tries the deletion right after this
|
||||
/// process exits (the file is locked while we run). Empty directories left
|
||||
/// behind under the removed roots are swept afterwards.
|
||||
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::fs::remove_file(exe)
|
||||
.with_context(|| format!("cannot remove {}", exe.display()))?;
|
||||
// Sweep empty directories upward, but never above a removed root.
|
||||
let mut dir = exe.parent();
|
||||
while let Some(d) = dir {
|
||||
if !roots.iter().any(|r| d.starts_with(r)) {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_dir(d).is_err() {
|
||||
break;
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed:".green(),
|
||||
exe.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
// The file to delete once this process exits: the renamed copy when
|
||||
// the up-front rename worked, the original path otherwise.
|
||||
let target = renamed.unwrap_or(exe);
|
||||
// A temporary batch file retries the deletion for a few seconds (the
|
||||
// image stays locked until we exit), sweeps the directories left
|
||||
// empty, then deletes itself. A .bat avoids every cmd /C command-line
|
||||
// quoting pitfall.
|
||||
let bat = write_cleanup_bat(exe, target, roots);
|
||||
if let Some(b) = &bat {
|
||||
// raw_arg passes the command line verbatim: regular .arg()
|
||||
// backslash-escapes embedded quotes, which cmd.exe does not
|
||||
// understand.
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
let mut cleanup = std::process::Command::new("cmd");
|
||||
cleanup.raw_arg("/C");
|
||||
cleanup.raw_arg(format!("\"{}\"", b.display()));
|
||||
let _ = cleanup.spawn();
|
||||
}
|
||||
// Belt and braces: if something keeps the file locked, the standard
|
||||
// uninstaller mechanism deletes it at the next reboot.
|
||||
let wide: Vec<u16> = target
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let at_reboot = unsafe {
|
||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||
};
|
||||
if bat.is_none() && !at_reboot {
|
||||
bail!(
|
||||
"cannot schedule deletion of {} — delete it manually",
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
if renamed.is_some() {
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
bail!(
|
||||
"automatic removal of the executable is not supported on this platform; delete {} manually",
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the running executable out of the way (Windows allows renaming a
|
||||
/// running image — only deletion is refused while it executes). Returns the
|
||||
/// new path on success.
|
||||
#[cfg(windows)]
|
||||
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
|
||||
let name = exe.file_name()?.to_string_lossy();
|
||||
let new = exe.with_file_name(format!("{name}.deleteme"));
|
||||
std::fs::rename(exe, &new).ok()?;
|
||||
Some(new)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Write a temporary batch file that deletes the target executable once it
|
||||
/// is unlocked, sweeps the directories left empty and then removes itself.
|
||||
#[cfg(windows)]
|
||||
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let crlf = "\r\n";
|
||||
let q = "\"";
|
||||
let mut script = String::new();
|
||||
let mut line = |s: &str| {
|
||||
script.push_str(s);
|
||||
script.push_str(crlf);
|
||||
};
|
||||
line("@echo off");
|
||||
line("set n=0");
|
||||
line(":retry");
|
||||
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
|
||||
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
|
||||
line(&format!(
|
||||
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
|
||||
target.display(),
|
||||
exe.display()
|
||||
));
|
||||
line("set /a n+=1");
|
||||
line("if %n% GEQ 60 goto end");
|
||||
line("ping -n 2 127.0.0.1 >nul");
|
||||
line("goto retry");
|
||||
line(":sweep");
|
||||
if let Some(parent) = exe.parent() {
|
||||
if roots.iter().any(|r| parent.starts_with(r)) {
|
||||
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
|
||||
}
|
||||
}
|
||||
for root in roots {
|
||||
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
|
||||
}
|
||||
line(":end");
|
||||
line(&format!("del {q}%~f0{q} >nul 2>&1"));
|
||||
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
|
||||
std::fs::write(&path, script).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removes_readonly_tree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
let file = sub.join("readonly.txt");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
// Read-only files are a classic cause of "Access denied" on Windows.
|
||||
let mut perms = std::fs::metadata(&file).unwrap().permissions();
|
||||
#[allow(clippy::permissions_set_readonly_false)]
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(&file, perms).unwrap();
|
||||
|
||||
let mut locked: Vec<PathBuf> = Vec::new();
|
||||
remove_tree(dir.path(), &mut locked);
|
||||
assert!(!dir.path().exists(), "tree should be fully removed");
|
||||
assert!(locked.is_empty(), "unexpected locked paths: {locked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_missing_tree() {
|
||||
let mut locked: Vec<PathBuf> = Vec::new();
|
||||
remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked);
|
||||
assert!(locked.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! sessions: the registry of every agent and REPL session.
|
||||
|
||||
use super::*;
|
||||
use crate::output::{print_json, status_style, Table};
|
||||
use crate::sessions::{self, SessionRecord};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionRow {
|
||||
id: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
status: String,
|
||||
pid: Option<u32>,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
started_at: String,
|
||||
ended_at: Option<String>,
|
||||
duration_s: Option<u64>,
|
||||
exit_code: Option<i32>,
|
||||
log: Option<String>,
|
||||
}
|
||||
|
||||
fn row_of(r: &SessionRecord) -> SessionRow {
|
||||
SessionRow {
|
||||
id: r.id.clone(),
|
||||
kind: r.kind.clone(),
|
||||
agent: r.agent.clone(),
|
||||
status: r.status.clone(),
|
||||
pid: r.pid,
|
||||
cwd: r.cwd.clone(),
|
||||
project: r.project.clone(),
|
||||
started_at: r.started_at.clone(),
|
||||
ended_at: r.ended_at.clone(),
|
||||
duration_s: sessions::duration_s(r),
|
||||
exit_code: r.exit_code,
|
||||
log: r.log.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
project: Option<&str>,
|
||||
status: Option<&str>,
|
||||
show: Option<&str>,
|
||||
resume: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
if let Some(id) = show {
|
||||
return show_one(app, id);
|
||||
}
|
||||
if let Some(id) = resume {
|
||||
return resume_session(app, id);
|
||||
}
|
||||
sessions::reconcile(app)?;
|
||||
let sf = sessions::load(app)?;
|
||||
let mut rows: Vec<SessionRow> = sf
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a)))
|
||||
.filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p)))
|
||||
.filter(|r| status.map_or(true, |s| r.status == s))
|
||||
.map(row_of)
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let color = app.color();
|
||||
let theme = app.theme();
|
||||
let mut table = Table::new(vec!["SESSION", "AGENT", "STATUS", "DURATION", "EXIT"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
short_id(&r.id),
|
||||
r.agent
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("({})", r.kind)),
|
||||
status_style(&r.status, theme, color),
|
||||
r.duration_s
|
||||
.map(format_duration)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
r.exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
if rows.is_empty() {
|
||||
app.log
|
||||
.info("no sessions recorded yet — 'am start <agent> --background' or run 'am'");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn show_one(app: &App, id: &str) -> Result<i32> {
|
||||
sessions::reconcile(app)?;
|
||||
let sf = sessions::load(app)?;
|
||||
let Some(r) = sf.sessions.get(id).cloned() else {
|
||||
bail!("unknown session '{id}' — 'am sessions' lists every session");
|
||||
};
|
||||
if app.json() {
|
||||
print_json(&row_of(&r));
|
||||
return Ok(0);
|
||||
}
|
||||
println!("session {}", r.id);
|
||||
println!(" kind: {}", r.kind);
|
||||
println!(" agent: {}", r.agent.as_deref().unwrap_or("—"));
|
||||
println!(" status: {}", r.status);
|
||||
println!(" pid: {}", r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()));
|
||||
println!(" cwd: {}", r.cwd.as_deref().unwrap_or("—"));
|
||||
println!(" project: {}", r.project.as_deref().unwrap_or("—"));
|
||||
println!(" started: {}", r.started_at);
|
||||
println!(" ended: {}", r.ended_at.as_deref().unwrap_or("—"));
|
||||
if let Some(d) = sessions::duration_s(&r) {
|
||||
println!(" duration: {}", format_duration(d));
|
||||
}
|
||||
if let Some(c) = r.exit_code {
|
||||
println!(" exit code: {c}");
|
||||
}
|
||||
if let Some(l) = &r.log {
|
||||
let p = std::path::PathBuf::from(l);
|
||||
if p.exists() {
|
||||
println!(" log: {l}");
|
||||
let tail = crate::process::last_lines(&p, 8);
|
||||
if !tail.is_empty() {
|
||||
println!(" --- log excerpt ---");
|
||||
println!("{tail}");
|
||||
}
|
||||
} else {
|
||||
println!(" log: {l} (missing)");
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Resume a finished session: relaunch the agent in the background with
|
||||
/// its recorded arguments and working directory (issue #32).
|
||||
fn resume_session(app: &App, id: &str) -> Result<i32> {
|
||||
sessions::reconcile(app)?;
|
||||
let sf = sessions::load(app)?;
|
||||
let Some(r) = sf.sessions.get(id).cloned() else {
|
||||
bail!("unknown session '{id}' — 'am sessions' lists every session");
|
||||
};
|
||||
if r.kind != "agent" {
|
||||
bail!("session '{id}' is a REPL session — start it again with 'am'");
|
||||
}
|
||||
if r.status == "running" {
|
||||
app.log.info(&format!("session '{id}' is still running"));
|
||||
return Ok(0);
|
||||
}
|
||||
let Some(agent_name) = r.agent.clone() else {
|
||||
bail!("session '{id}' has no agent");
|
||||
};
|
||||
require_agent(app, &agent_name)?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would resume session {id}: start {} --background with {} arg(s)",
|
||||
agent_name,
|
||||
r.args.len()
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let cwd = r.cwd.as_deref().map(std::path::Path::new);
|
||||
let opts = crate::cli::StartArgs {
|
||||
agent: Some(agent_name.clone()),
|
||||
background: true,
|
||||
args: r.args.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
app.log.info(&format!(
|
||||
"resuming session {id}: {} ({} arg(s))",
|
||||
agent_name,
|
||||
r.args.len()
|
||||
));
|
||||
crate::commands::run_cmd::start_with_cwd(app, &opts, cwd)
|
||||
}
|
||||
|
||||
/// "20260815_143926_a1b2c3" -> "20260815_143926"
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(15).collect()
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! stats: usage statistics computed from the event journal.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{self, EventKind};
|
||||
use crate::output::{print_json, Table};
|
||||
use anyhow::Context;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct AgentStats {
|
||||
pub agent: String,
|
||||
pub launches: u64,
|
||||
pub total_duration_s: u64,
|
||||
pub avg_duration_s: u64,
|
||||
pub failures: u64,
|
||||
pub installs: u64,
|
||||
pub updates: u64,
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
/// Cutoff instant for a period: 7d / 30d / 90d, or None for everything.
|
||||
pub fn cutoff(period: Option<&str>) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
|
||||
let days = match period {
|
||||
None | Some("all") => return Ok(None),
|
||||
Some("7d") => 7,
|
||||
Some("30d") => 30,
|
||||
Some("90d") => 90,
|
||||
Some(other) => bail!("invalid period '{other}' — expected 7d, 30d, 90d or all"),
|
||||
};
|
||||
Ok(Some(chrono::Utc::now() - chrono::Duration::days(days)))
|
||||
}
|
||||
|
||||
/// Aggregate journal events into per-agent statistics.
|
||||
pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<Vec<AgentStats>> {
|
||||
let cut = cutoff(period)?;
|
||||
let events = events::read_events(&app.events_dir(), 0);
|
||||
let mut map: BTreeMap<String, AgentStats> = BTreeMap::new();
|
||||
let mut dur_count: BTreeMap<String, u64> = BTreeMap::new();
|
||||
for ev in events {
|
||||
if let Some(c) = cut {
|
||||
let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) else {
|
||||
continue;
|
||||
};
|
||||
if ts.with_timezone(&chrono::Utc) < c {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(name) = ev.agent else {
|
||||
continue;
|
||||
};
|
||||
if let Some(a) = agent {
|
||||
if name != a {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let s = map.entry(name.clone()).or_default();
|
||||
s.agent = name.clone();
|
||||
match ev.kind {
|
||||
EventKind::Start | EventKind::Run => s.launches += 1,
|
||||
EventKind::Install => s.installs += 1,
|
||||
EventKind::Update => s.updates += 1,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(d) = ev.duration_s {
|
||||
s.total_duration_s += d;
|
||||
*dur_count.entry(name.clone()).or_default() += 1;
|
||||
}
|
||||
if let Some(code) = ev.exit_code {
|
||||
if code != 0 {
|
||||
s.failures += 1;
|
||||
}
|
||||
}
|
||||
s.last_used = Some(ev.ts);
|
||||
}
|
||||
for (name, s) in map.iter_mut() {
|
||||
let n = dur_count.get(name).copied().unwrap_or(0);
|
||||
s.avg_duration_s = if n > 0 { s.total_duration_s / n } else { 0 };
|
||||
}
|
||||
Ok(map.into_values().collect())
|
||||
}
|
||||
|
||||
/// Top 10 agents by launch count (issue #37).
|
||||
pub fn top(app: &App, period: Option<&str>) -> Result<Vec<AgentStats>> {
|
||||
let mut rows = aggregate(app, None, period)?;
|
||||
rows.sort_by(|a, b| b.launches.cmp(&a.launches).then(b.total_duration_s.cmp(&a.total_duration_s)));
|
||||
rows.truncate(10);
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Markdown weekly/monthly report over the last N days (issue #37).
|
||||
pub fn report_md(app: &App, days: u32) -> String {
|
||||
let cut = chrono::Utc::now() - chrono::Duration::days(days as i64);
|
||||
let events = events::read_events(&app.events_dir(), 0);
|
||||
let mut total_launches = 0u64;
|
||||
let mut failures = 0u64;
|
||||
let mut changes = 0u64;
|
||||
let mut duration = 0u64;
|
||||
for ev in events {
|
||||
let ok = chrono::DateTime::parse_from_rfc3339(&ev.ts)
|
||||
.map(|t| t.with_timezone(&chrono::Utc) >= cut)
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
match ev.kind {
|
||||
EventKind::Start | EventKind::Run => total_launches += 1,
|
||||
EventKind::Install | EventKind::Update | EventKind::Uninstall => changes += 1,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(d) = ev.duration_s {
|
||||
duration += d;
|
||||
}
|
||||
if let Some(c) = ev.exit_code {
|
||||
if c != 0 {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
|
||||
let top_rows = top(app, None).unwrap_or_default();
|
||||
let mut md = format!(
|
||||
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
|
||||
duration
|
||||
);
|
||||
if !top_rows.is_empty() {
|
||||
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
|
||||
for r in &top_rows {
|
||||
md.push_str(&format!(
|
||||
"| {} | {} | {}s | {} |\n",
|
||||
r.agent, r.launches, r.total_duration_s, r.failures
|
||||
));
|
||||
}
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
|
||||
let rows = aggregate(app, agent, period)?;
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let color = app.color();
|
||||
let theme = app.theme();
|
||||
if rows.is_empty() {
|
||||
app.log.info("no usage recorded yet — run or start an agent first");
|
||||
return Ok(0);
|
||||
}
|
||||
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
|
||||
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "AVG", "FAIL", "LAST"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
r.agent.clone(),
|
||||
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
|
||||
format_duration(r.total_duration_s),
|
||||
format_duration(r.avg_duration_s),
|
||||
if r.failures > 0 {
|
||||
crate::output::status_style("error", theme, color)
|
||||
} else {
|
||||
r.failures.to_string()
|
||||
},
|
||||
r.last_used
|
||||
.as_deref()
|
||||
.map(|t| t[..t.len().min(10)].to_string())
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am top : the most used agents (issue #37).
|
||||
pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
|
||||
let rows = top(app, period)?;
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let color = app.color();
|
||||
let theme = app.theme();
|
||||
if rows.is_empty() {
|
||||
app.log.info("no usage recorded yet");
|
||||
return Ok(0);
|
||||
}
|
||||
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
|
||||
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "FAIL"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
r.agent.clone(),
|
||||
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
|
||||
format_duration(r.total_duration_s),
|
||||
if r.failures > 0 {
|
||||
crate::output::status_style("error", theme, color)
|
||||
} else {
|
||||
r.failures.to_string()
|
||||
},
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am report : write the markdown digest (issue #37).
|
||||
pub fn run_report(
|
||||
app: &App,
|
||||
last_week: bool,
|
||||
last_month: bool,
|
||||
output: Option<&std::path::Path>,
|
||||
) -> Result<i32> {
|
||||
let days: u32 = if last_week { 7 } else { 30 };
|
||||
let md = report_md(app, days);
|
||||
let path = match output {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => {
|
||||
let stamp = chrono::Local::now().format("%Y%m%d");
|
||||
app.events_dir().join(format!("report-{stamp}.md"))
|
||||
}
|
||||
};
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would write {}", path.display()));
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(&path, md)
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
app.log.success(&format!("report written to {}", path.display()));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// ASCII bar: relative weight of v against max.
|
||||
fn bar(v: u64, max: u64, width: usize) -> String {
|
||||
if max == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let n = ((v * width as u64) / max).max(if v > 0 { 1 } else { 0 }) as usize;
|
||||
format!("{}{}", "█".repeat(n), "░".repeat(width.saturating_sub(n)))
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
if secs == 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn app_with_events(events_jsonl: &str) -> crate::app::App {
|
||||
// The TempDir guard would delete the directory when this helper
|
||||
// returns; leak it on purpose so the App keeps a live events dir.
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
std::fs::write(dir.join("events-test.jsonl"), events_jsonl).unwrap();
|
||||
// Rename into the right month file so read_events picks it up.
|
||||
let month = chrono::Utc::now().format("%Y%m");
|
||||
std::fs::rename(
|
||||
dir.join("events-test.jsonl"),
|
||||
dir.join(format!("events-{month}.jsonl")),
|
||||
)
|
||||
.unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_counts_durations_and_failures() {
|
||||
let lines = [
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
|
||||
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
|
||||
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
|
||||
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
|
||||
];
|
||||
let app = app_with_events(&lines.join("\n"));
|
||||
let rows = aggregate(&app, None, None).unwrap();
|
||||
let a = rows.iter().find(|r| r.agent == "a").unwrap();
|
||||
assert_eq!(a.launches, 2);
|
||||
assert_eq!(a.total_duration_s, 40);
|
||||
assert_eq!(a.avg_duration_s, 20);
|
||||
assert_eq!(a.failures, 0);
|
||||
assert_eq!(a.installs, 1);
|
||||
let b = rows.iter().find(|r| r.agent == "b").unwrap();
|
||||
assert_eq!(b.failures, 1);
|
||||
assert_eq!(b.launches, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_cuts_old_events() {
|
||||
let app = app_with_events(
|
||||
r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#,
|
||||
);
|
||||
assert!(aggregate(&app, None, Some("30d")).unwrap().is_empty());
|
||||
assert_eq!(aggregate(&app, None, None).unwrap().len(), 1);
|
||||
assert!(cutoff(Some("bad")).is_err());
|
||||
}
|
||||
}
|
||||
+41
-18
@@ -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,179 @@
|
||||
//! timeline: one chronological view over the event journal and the REPL
|
||||
//! command history.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{self, Event};
|
||||
use crate::history::{self, HistoryEntry};
|
||||
use crate::output::print_json;
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TimelineRow {
|
||||
pub ts: String,
|
||||
pub source: String,
|
||||
pub kind: String,
|
||||
pub agent: Option<String>,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Merge events and history entries into one chronological list, most
|
||||
/// recent first, filtered by agent/project/date and capped by limit.
|
||||
pub fn collect(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
project: Option<&str>,
|
||||
since: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TimelineRow>> {
|
||||
let cut = match since {
|
||||
Some(s) => Some(crate::commands::log_cmd::parse_since(s)?),
|
||||
None => None,
|
||||
};
|
||||
let mut rows: Vec<TimelineRow> = Vec::new();
|
||||
for ev in events::read_events(&app.events_dir(), 0) {
|
||||
if agent.map_or(true, |a| ev.agent.as_deref() == Some(a))
|
||||
&& project.map_or(true, |p| ev.project.as_deref() == Some(p))
|
||||
{
|
||||
if let Some(c) = cut {
|
||||
if chrono::DateTime::parse_from_rfc3339(&ev.ts)
|
||||
.map(|t| t < c)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rows.push(event_row(&ev));
|
||||
}
|
||||
}
|
||||
// REPL history entries carry no agent/project: include them only
|
||||
// when no such filter is set.
|
||||
if agent.is_none() && project.is_none() {
|
||||
for (_, e) in crate::commands::history_cmd::filter(app, None, None, None, false, None, 0) {
|
||||
let ts_ok = match cut {
|
||||
Some(c) => chrono::DateTime::parse_from_rfc3339(&e.ts)
|
||||
.map(|t| t >= c)
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
};
|
||||
if !ts_ok {
|
||||
continue;
|
||||
}
|
||||
rows.push(TimelineRow {
|
||||
ts: e.ts.clone(),
|
||||
source: "repl".to_string(),
|
||||
kind: e.kind.clone(),
|
||||
agent: e.agent.clone(),
|
||||
summary: e.cmd.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
rows.truncate(if limit > 0 { limit } else { rows.len() });
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn event_row(ev: &Event) -> TimelineRow {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(p) = ev.pid {
|
||||
parts.push(format!("pid {p}"));
|
||||
}
|
||||
if let Some(d) = ev.duration_s {
|
||||
parts.push(format!("{d}s"));
|
||||
}
|
||||
if let Some(c) = ev.exit_code {
|
||||
parts.push(format!("exit {c}"));
|
||||
}
|
||||
TimelineRow {
|
||||
ts: ev.ts.clone(),
|
||||
source: "journal".to_string(),
|
||||
kind: ev.kind.as_str().to_string(),
|
||||
agent: ev.agent.clone(),
|
||||
summary: if parts.is_empty() {
|
||||
ev.project.clone().unwrap_or_default()
|
||||
} else {
|
||||
parts.join(", ")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
project: Option<&str>,
|
||||
since: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<i32> {
|
||||
let rows = collect(app, agent, project, since, limit)?;
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
for r in &rows {
|
||||
let ts = r.ts.get(0..16).unwrap_or(&r.ts);
|
||||
let actor = r.agent.as_deref().unwrap_or("repl");
|
||||
println!("{ts} {:<9} {:<16} {}", r.kind, actor, r.summary);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
app.log.info("no activity recorded yet");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn app_with(lines: &[&str]) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
let month = chrono::Utc::now().format("%Y%m");
|
||||
let path = dir.join(format!("events-{month}.jsonl"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, lines.join("\n")).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_events_and_history_chronologically() {
|
||||
let app = app_with(&[
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":5}"#,
|
||||
r#"{"ts":"2026-08-03T10:00:00Z","kind":"start","agent":"a","pid":42}"#,
|
||||
]);
|
||||
history::record(&app, "s1", &history::HistoryEntry::now("list --all")).unwrap();
|
||||
let rows = collect(&app, None, None, None, 0).unwrap();
|
||||
assert!(rows.len() >= 3, "events + history: {rows:?}");
|
||||
// Ordre antichronologique strict.
|
||||
let ts: Vec<&str> = rows.iter().map(|r| r.ts.as_str()).collect();
|
||||
let mut sorted = ts.clone();
|
||||
sorted.sort_by(|a, b| b.cmp(a));
|
||||
assert_eq!(ts, sorted);
|
||||
// Filtres.
|
||||
assert_eq!(collect(&app, Some("a"), None, None, 0).unwrap().len(), 2);
|
||||
assert_eq!(collect(&app, Some("zzz"), None, None, 0).unwrap().len(), 0);
|
||||
assert_eq!(collect(&app, None, None, None, 2).unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
//! uninstall: stop the agent if needed, remove its files, bins and state
|
||||
//! entry; --purge also removes logs and the user-config definition.
|
||||
//! External agents (installed outside agent-manager) are removed through
|
||||
//! their package manager when the catalog knows it, or by deleting the
|
||||
//! executables found on PATH otherwise.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
let agent = require_agent(app, agent_name)?;
|
||||
let entry = app.state.get(&agent.name).ok().flatten();
|
||||
let Some(entry) = entry else {
|
||||
app.log.info(&format!(
|
||||
"agent '{}' is not managed by agent-manager — nothing to uninstall",
|
||||
agent.name
|
||||
));
|
||||
return Ok(0);
|
||||
return uninstall_external(app, agent);
|
||||
};
|
||||
|
||||
// Stop it first when running.
|
||||
@@ -57,6 +58,7 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
remove_from_user_config(app, &agent.name)?;
|
||||
}
|
||||
|
||||
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
|
||||
app.log.success(&format!(
|
||||
"uninstalled {} (files removed{})",
|
||||
agent.title(),
|
||||
@@ -65,6 +67,135 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Build the package-manager uninstall command for an external agent whose
|
||||
/// install method is a known package manager and whose package is recorded.
|
||||
fn package_plan(agent: &AgentDef) -> Option<Vec<String>> {
|
||||
use crate::config::InstallType;
|
||||
let spec = agent.install.as_ref()?;
|
||||
for m in spec.all_methods() {
|
||||
let Some(pkg) = m.package.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let plan = match m.kind {
|
||||
InstallType::Npm => {
|
||||
vec!["npm", "uninstall", "--global", pkg]
|
||||
}
|
||||
InstallType::Pip => {
|
||||
if which::which("pip").is_ok() {
|
||||
vec!["pip", "uninstall", "-y", pkg]
|
||||
} else if which::which("python3").is_ok() {
|
||||
vec!["python3", "-m", "pip", "uninstall", "-y", pkg]
|
||||
} else if which::which("python").is_ok() {
|
||||
vec!["python", "-m", "pip", "uninstall", "-y", pkg]
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
InstallType::Uv => vec!["uv", "tool", "uninstall", pkg],
|
||||
InstallType::Cargo => vec!["cargo", "uninstall", pkg],
|
||||
InstallType::Bun => vec!["bun", "remove", "--global", pkg],
|
||||
_ => continue,
|
||||
};
|
||||
return Some(plan.into_iter().map(|s| s.to_string()).collect());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Best-effort removal of an agent installed outside agent-manager: first the
|
||||
/// package-manager command when the catalog knows one, then the executable
|
||||
/// files left on PATH (and their directory when it is dedicated to the
|
||||
/// agent, e.g. AppData\Local\atomic-agent).
|
||||
fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
|
||||
let first_token = agent.first_token();
|
||||
|
||||
// 1. Package-manager plan (npm, pip, uv, cargo, bun).
|
||||
if let Some(tokens) = package_plan(agent) {
|
||||
if which::which(&tokens[0]).is_ok() {
|
||||
app.log.info(&format!(
|
||||
"agent '{}' is external (installed outside agent-manager) — uninstall with: {}",
|
||||
agent.name,
|
||||
tokens.join(" ")
|
||||
));
|
||||
if !app.confirm("Run this command?")? {
|
||||
app.log.info("cancelled — nothing was uninstalled");
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would run: {}", tokens.join(" ")));
|
||||
} else {
|
||||
// resolve_program handles Windows .cmd/.bat shims (npm,
|
||||
// pip, ...) which std::process::Command cannot launch.
|
||||
let (prog, prefix) = crate::runner::resolve_program(&tokens[0]);
|
||||
let mut args = prefix;
|
||||
args.extend(tokens[1..].iter().cloned());
|
||||
let status = ProcessCommand::new(&prog)
|
||||
.args(&args)
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", tokens.join(" ")))?;
|
||||
if !status.success() {
|
||||
app.log.warn("the command failed — removing the files it left behind");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Whatever executable is still on PATH, delete it directly (and its
|
||||
// directory when it belongs to the agent).
|
||||
let still = first_token.as_deref().and_then(|t| which::which(t).ok());
|
||||
if let Some(path) = still {
|
||||
let parent = path.parent().map(|p| p.to_path_buf());
|
||||
let dedicated = parent
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name))
|
||||
.unwrap_or(false);
|
||||
let target: PathBuf = if dedicated {
|
||||
parent.clone().unwrap_or_else(|| path.clone())
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
app.log.info(&format!(
|
||||
"remove leftover {} {}",
|
||||
if dedicated { "directory" } else { "file" },
|
||||
target.display()
|
||||
));
|
||||
if !app.confirm("Delete it?")? {
|
||||
app.log.info(&format!("'{}' left in place", target.display()));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove {}", target.display()));
|
||||
} else if dedicated {
|
||||
std::fs::remove_dir_all(&target)
|
||||
.with_context(|| format!("cannot remove {}", target.display()))?;
|
||||
} else {
|
||||
std::fs::remove_file(&target)
|
||||
.with_context(|| format!("cannot remove {}", target.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Final verification.
|
||||
let gone = first_token
|
||||
.as_deref()
|
||||
.and_then(|t| which::which(t).ok())
|
||||
.is_none();
|
||||
if gone {
|
||||
app.emit(
|
||||
&Event::now(EventKind::Uninstall)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason("external"),
|
||||
);
|
||||
app.log
|
||||
.success(&format!("uninstalled {} (external)", agent.title()));
|
||||
} else {
|
||||
app.log.warn(&format!(
|
||||
"{} is still present on PATH — remove it manually",
|
||||
agent.title()
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Remove the agent entry from the user config file (--purge).
|
||||
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||
let Some(path) = &app.config_origin else {
|
||||
@@ -111,3 +242,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent_from_yaml(yaml: &str) -> AgentDef {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wrap {
|
||||
agents: Vec<AgentDef>,
|
||||
}
|
||||
let w: Wrap = serde_yaml::from_str(yaml).unwrap();
|
||||
w.agents.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_npm() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: x
|
||||
install:
|
||||
type: npm
|
||||
package: "@scope/pkg"
|
||||
run: x
|
||||
"#,
|
||||
);
|
||||
let plan = package_plan(&a).unwrap();
|
||||
assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_cargo() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: x
|
||||
install:
|
||||
type: cargo
|
||||
package: my-crate
|
||||
run: x
|
||||
"#,
|
||||
);
|
||||
let plan = package_plan(&a).unwrap();
|
||||
assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_binary_is_none() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: y
|
||||
install:
|
||||
type: binary
|
||||
repo: owner/repo
|
||||
binary: y
|
||||
run: y
|
||||
"#,
|
||||
);
|
||||
assert!(package_plan(&a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_unknown_method_is_none() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: z
|
||||
install:
|
||||
type: git
|
||||
repo: https://example.com/z.git
|
||||
run: z
|
||||
"#,
|
||||
);
|
||||
assert!(package_plan(&a).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! reinstall when an update exists.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::{Runner, SystemRunner};
|
||||
use anyhow::{bail, Result};
|
||||
@@ -96,6 +97,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
new_entry.updated_at = Some(installers::now_rfc3339());
|
||||
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
app.state.set(&new_entry)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Update)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![format!("method={}", method.kind)]),
|
||||
);
|
||||
crate::hooks::run_hooks(
|
||||
app,
|
||||
"on_update",
|
||||
&std::env::current_dir().unwrap_or_default(),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"updated {} to v{version}",
|
||||
agent.title()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! watch: supervise one agent and optionally restart it on crash.
|
||||
|
||||
use super::*;
|
||||
use crate::cli::StartArgs;
|
||||
use crate::process;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One supervision check. Returns whether the agent is running afterwards.
|
||||
/// Restarts are background starts (event + session recorded as usual).
|
||||
pub fn check_once(app: &App, agent_name: &str, restart: bool, notify: bool) -> bool {
|
||||
let entry = app.state.get(agent_name).ok().flatten();
|
||||
let running = entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.pid)
|
||||
.map(process::is_running)
|
||||
.unwrap_or(false);
|
||||
if running {
|
||||
return true;
|
||||
}
|
||||
if !restart {
|
||||
app.log.warn(&format!("'{agent_name}' is not running"));
|
||||
return false;
|
||||
}
|
||||
let agent = match app.catalog.resolve(agent_name) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
app.log.error(&format!("unknown agent '{agent_name}'"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
app.log.warn(&format!("'{agent_name}' is not running — restarting"));
|
||||
let opts = StartArgs {
|
||||
agent: Some(agent_name.to_string()),
|
||||
background: true,
|
||||
..Default::default()
|
||||
};
|
||||
match run_cmd::start(app, &opts) {
|
||||
Ok(_) => {
|
||||
if notify {
|
||||
process::desktop_notify("agent-manager", &format!("{} restarted", agent.title()));
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
app.log.error(&format!("restart failed: {e:#}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(app: &App, agent: &str, restart: bool, notify: bool, interval: u64) -> Result<i32> {
|
||||
require_agent(app, agent)?;
|
||||
let interval = interval.max(1);
|
||||
app.log.info(&format!("watching '{agent}' every {interval}s (Ctrl-C to stop)"));
|
||||
let mut backoff = 1u64;
|
||||
loop {
|
||||
let ok = check_once(app, agent, restart, notify);
|
||||
if ok {
|
||||
backoff = 1;
|
||||
std::thread::sleep(Duration::from_secs(interval));
|
||||
} else if restart {
|
||||
app.log.warn(&format!("retrying in {backoff}s"));
|
||||
std::thread::sleep(Duration::from_secs(backoff));
|
||||
backoff = (backoff * 2).min(60);
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_secs(interval));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn check_once_reports_dead_agent_without_restart() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
// Agent absent de l état : pas running, pas de restart -> false.
|
||||
assert!(!check_once(&app, "claude-code", false, false));
|
||||
// Agent inconnu avec restart : erreur loguée, false.
|
||||
assert!(!check_once(&app, "inconnu", true, false));
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -35,6 +35,9 @@ pub struct Config {
|
||||
pub include: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub agents: Vec<AgentDef>,
|
||||
/// Per-project profiles: root, default agent, env and hooks.
|
||||
#[serde(default)]
|
||||
pub projects: BTreeMap<String, ProjectProfile>,
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
@@ -50,7 +53,8 @@ pub struct Settings {
|
||||
/// Directory for logs.
|
||||
#[serde(default)]
|
||||
pub log_dir: Option<String>,
|
||||
/// Shell used to execute install scripts (defaults to sh on Unix).
|
||||
/// Shell used to execute install scripts and to run system commands
|
||||
/// in the interactive prompt (defaults to the user's default shell).
|
||||
#[serde(default)]
|
||||
pub default_shell: Option<String>,
|
||||
/// Try to install missing dependencies automatically.
|
||||
@@ -65,6 +69,31 @@ pub struct Settings {
|
||||
/// GitHub repository (owner/repo) used by the self-update command.
|
||||
#[serde(default)]
|
||||
pub self_update_repo: Option<String>,
|
||||
/// Color theme for the output (see 'am help theme' for the list; the
|
||||
/// --theme flag overrides it).
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
/// Lifecycle hooks: on_install / on_start / on_stop / on_update command
|
||||
/// lines (global; project profiles can add their own).
|
||||
#[serde(default)]
|
||||
pub hooks: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// A project profile: everything am needs to know about one project.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectProfile {
|
||||
/// Root directory of the project (accepts ~ and env variables).
|
||||
pub root: String,
|
||||
/// Agent launched by 'am start' without arguments inside the project.
|
||||
#[serde(default)]
|
||||
pub default_agent: Option<String>,
|
||||
/// Environment variables injected when launching the default agent.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Hooks: on_start / on_stop / on_install / on_update command lines.
|
||||
#[serde(default)]
|
||||
pub hooks: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -472,6 +501,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.self_update_repo.is_some() {
|
||||
s.self_update_repo = o.self_update_repo;
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
for (k, v) in overlay.aliases {
|
||||
base.aliases.insert(k, v);
|
||||
}
|
||||
@@ -485,6 +517,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
base.agents.push(agent);
|
||||
}
|
||||
}
|
||||
for (k, v) in overlay.projects {
|
||||
base.projects.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a merged config. Returns a list of human-readable problems.
|
||||
@@ -623,6 +658,7 @@ mod tests {
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len());
|
||||
assert!(cfg.agents.iter().any(|a| a.name == "claude-code"));
|
||||
assert!(cfg.agents.iter().any(|a| a.name == "deepseek-harness"));
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
//! Project context detection: git root + branch and the technology
|
||||
//! stack of a directory (marker files). Cached per process so repeated
|
||||
//! calls (event emission, sessions) never re-run subprocesses.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct ProjectContext {
|
||||
/// Root of the enclosing git repository (or None).
|
||||
pub root: Option<String>,
|
||||
/// Current branch (or None).
|
||||
pub branch: Option<String>,
|
||||
/// Detected technology stack, e.g. ["rust", "node"].
|
||||
pub stack: Vec<String>,
|
||||
}
|
||||
|
||||
/// Marker file -> stack label. Checked walking up from the directory to the
|
||||
/// filesystem root; the first directory containing a marker wins.
|
||||
const MARKERS: &[(&str, &str)] = &[
|
||||
("Cargo.toml", "rust"),
|
||||
("package.json", "node"),
|
||||
("pyproject.toml", "python"),
|
||||
("requirements.txt", "python"),
|
||||
("setup.py", "python"),
|
||||
("go.mod", "go"),
|
||||
("bun.lockb", "bun"),
|
||||
("bun.lock", "bun"),
|
||||
("Gemfile", "ruby"),
|
||||
];
|
||||
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, ProjectContext>>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<PathBuf, ProjectContext>> {
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Detect the project context of a directory (cached per process).
|
||||
pub fn detect(cwd: &Path) -> ProjectContext {
|
||||
if let Some(hit) = cache().lock().unwrap().get(cwd).cloned() {
|
||||
return hit;
|
||||
}
|
||||
let ctx = detect_uncached(cwd);
|
||||
cache().lock().unwrap().insert(cwd.to_path_buf(), ctx.clone());
|
||||
ctx
|
||||
}
|
||||
|
||||
fn detect_uncached(cwd: &Path) -> ProjectContext {
|
||||
let (root, branch) = git_info(cwd);
|
||||
let stack = detect_stack(cwd);
|
||||
ProjectContext {
|
||||
root,
|
||||
branch,
|
||||
stack,
|
||||
}
|
||||
}
|
||||
|
||||
/// Git root and branch via git subprocesses (best effort, silent).
|
||||
fn git_info(cwd: &Path) -> (Option<String>, Option<String>) {
|
||||
let root = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
let branch = std::process::Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|b| !b.is_empty());
|
||||
(root, branch)
|
||||
}
|
||||
|
||||
/// Walk up from cwd collecting stack labels from marker files.
|
||||
pub fn detect_stack(cwd: &Path) -> Vec<String> {
|
||||
let mut dir = Some(cwd);
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
while let Some(d) = dir {
|
||||
for (marker, label) in MARKERS {
|
||||
if d.join(marker).is_file() && !out.iter().any(|s| s == label) {
|
||||
out.push(label.to_string());
|
||||
}
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Agents recommended for a detected stack (catalog slugs).
|
||||
pub fn recommended_agents(stack: &[String]) -> Vec<&'static str> {
|
||||
let mut out: Vec<&'static str> = Vec::new();
|
||||
if stack.contains(&"rust".to_string()) {
|
||||
out.push("smelt");
|
||||
}
|
||||
if stack.contains(&"python".to_string()) {
|
||||
out.push("aider");
|
||||
}
|
||||
if stack.contains(&"node".to_string()) {
|
||||
out.push("claude-code");
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push("claude-code");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_stack_from_markers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||
let stack = detect_stack(dir.path());
|
||||
assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]);
|
||||
assert_eq!(recommended_agents(&stack), vec!["smelt", "claude-code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_up_to_parent_directories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("go.mod"), "").unwrap();
|
||||
let sub = dir.path().join("src/deep");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
let stack = detect_stack(&sub);
|
||||
assert_eq!(stack, vec!["go".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_recommends_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let stack = detect_stack(dir.path());
|
||||
assert!(stack.is_empty());
|
||||
assert_eq!(recommended_agents(&stack), vec!["claude-code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_returns_same_context() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = detect(dir.path());
|
||||
let b = detect(dir.path());
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,13 @@ pub fn fix(app: &App) -> Result<Vec<String>> {
|
||||
repaired.push(format!("created {}", app.paths.install_dir.display()));
|
||||
repaired.push(format!("created {}", app.paths.log_dir.display()));
|
||||
|
||||
// Project cache: always refreshed (it derives from the journal).
|
||||
let cache = crate::projects::cache_file(app);
|
||||
if cache.exists() {
|
||||
std::fs::remove_file(&cache).ok();
|
||||
repaired.push(format!("project cache {} removed (rebuilt on next 'am projects')", cache.display()));
|
||||
}
|
||||
|
||||
// Corrupt state file: back it up and start fresh.
|
||||
if app.state.path().exists() && app.state.load().is_err() {
|
||||
let backup = app
|
||||
|
||||
+255
-38
@@ -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();
|
||||
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
//! Event journal: every action taken by agent-manager is appended as one
|
||||
//! JSON line per event in `events-YYYYMM.jsonl` files inside the state
|
||||
//! directory. The journal is the source of truth for stats, sessions and
|
||||
//! project aggregation (ROADMAP.md, axe 1).
|
||||
//!
|
||||
//! Design rules:
|
||||
//! * append-only, one compact JSON object per line;
|
||||
//! * monthly rotation (file name derived from the event timestamp);
|
||||
//! * corrupt lines are skipped on read (never fatal);
|
||||
//! * no long locks: plain append with a short retry loop for Windows
|
||||
//! file locks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Kind of event, serialized in snake_case.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
Start,
|
||||
Stop,
|
||||
Run,
|
||||
Restart,
|
||||
Install,
|
||||
Update,
|
||||
Uninstall,
|
||||
Doctor,
|
||||
Config,
|
||||
Repl,
|
||||
Shell,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
EventKind::Start => "start",
|
||||
EventKind::Stop => "stop",
|
||||
EventKind::Run => "run",
|
||||
EventKind::Restart => "restart",
|
||||
EventKind::Install => "install",
|
||||
EventKind::Update => "update",
|
||||
EventKind::Uninstall => "uninstall",
|
||||
EventKind::Doctor => "doctor",
|
||||
EventKind::Config => "config",
|
||||
EventKind::Repl => "repl",
|
||||
EventKind::Shell => "shell",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One journal entry. Optional fields are omitted from the JSON line.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
/// RFC 3339 timestamp (UTC), used to pick the monthly file.
|
||||
pub ts: String,
|
||||
pub kind: EventKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pid: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<String>,
|
||||
/// Variable names only — values (secrets) never enter the journal.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub env_keys: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub duration_s: Option<u64>,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// Event stamped with the current UTC time.
|
||||
pub fn now(kind: EventKind) -> Self {
|
||||
Self {
|
||||
ts: crate::installers::now_rfc3339(),
|
||||
kind,
|
||||
agent: None,
|
||||
pid: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
session: None,
|
||||
reason: None,
|
||||
args: Vec::new(),
|
||||
env_keys: Vec::new(),
|
||||
exit_code: None,
|
||||
duration_s: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
|
||||
self.agent = Some(agent.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_pid(mut self, pid: u32) -> Self {
|
||||
self.pid = Some(pid);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_project(mut self, project: Option<String>) -> Self {
|
||||
self.project = project;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_session(mut self, session: impl Into<String>) -> Self {
|
||||
self.session = Some(session.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
|
||||
self.reason = Some(reason.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_args(mut self, args: Vec<String>) -> Self {
|
||||
self.args = args;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_env_keys(mut self, env_keys: Vec<String>) -> Self {
|
||||
self.env_keys = env_keys;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_exit_code(mut self, code: i32) -> Self {
|
||||
self.exit_code = Some(code);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_duration(mut self, secs: u64) -> Self {
|
||||
self.duration_s = Some(secs);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted list of the variable names passed to a process. Values are never
|
||||
/// logged — only the keys — so secrets stay out of the journal.
|
||||
pub fn env_keys(env: &BTreeMap<String, String>) -> Vec<String> {
|
||||
let mut keys: Vec<String> = env.keys().cloned().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
|
||||
/// Month of an RFC 3339 timestamp: "2026-08-16T13:49:11Z" -> "202608".
|
||||
pub fn month_of(ts: &str) -> String {
|
||||
ts.chars()
|
||||
.take(7)
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Journal file name for a month: "202608" -> "events-202608.jsonl".
|
||||
pub fn file_name_for_month(month: &str) -> String {
|
||||
format!("events-{month}.jsonl")
|
||||
}
|
||||
|
||||
/// Month part of a journal file name, or None when the name does not look
|
||||
/// like a journal file ("events-202608.jsonl" -> "202608").
|
||||
pub fn month_from_file_name(name: &str) -> Option<String> {
|
||||
let rest = name.strip_prefix("events-")?.strip_suffix(".jsonl")?;
|
||||
(rest.len() == 6 && rest.chars().all(|c| c.is_ascii_digit())).then(|| rest.to_string())
|
||||
}
|
||||
|
||||
/// Sorted journal files of a directory, oldest first.
|
||||
pub fn journal_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.and_then(month_from_file_name)
|
||||
.is_some()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
/// Parse one journal file into events; corrupt lines are skipped silently.
|
||||
pub fn parse_file(path: &Path) -> Vec<Event> {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<Event>(l).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read events from a directory in chronological order, limited to the most
|
||||
/// recent `limit` events when limit > 0.
|
||||
pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
|
||||
let mut out: Vec<Event> = Vec::new();
|
||||
for f in journal_files(dir).iter().rev() {
|
||||
let mut evs = parse_file(f);
|
||||
evs.reverse(); // newest first within one file
|
||||
out.extend(evs);
|
||||
if limit > 0 && out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if limit > 0 {
|
||||
out.truncate(limit);
|
||||
}
|
||||
out.reverse(); // chronological order
|
||||
out
|
||||
}
|
||||
|
||||
/// Append one event as a single JSON line to the journal file for the
|
||||
/// event's month (created under `dir`). Returns the file written to.
|
||||
/// Retries briefly when the file is locked (Windows).
|
||||
pub fn append(dir: &Path, event: &Event) -> anyhow::Result<PathBuf> {
|
||||
let path = dir.join(file_name_for_month(&month_of(&event.ts)));
|
||||
append_to(&path, event)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Append one event to a specific journal file.
|
||||
pub fn append_to(path: &Path, event: &Event) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let line = serde_json::to_string(event)?;
|
||||
let mut last_err: Option<std::io::Error> = None;
|
||||
for attempt in 0..5 {
|
||||
match std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
Ok(mut f) => {
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
if attempt < 4 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"cannot append to journal {}: {}",
|
||||
path.display(),
|
||||
last_err
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "unknown error".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
/// Append-only journal living in a directory (the state directory).
|
||||
pub struct EventLog {
|
||||
dir: PathBuf,
|
||||
/// Cached (month, path) pair to avoid re-computing the file per event.
|
||||
current: Option<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
dir: dir.into(),
|
||||
current: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory the journal writes into.
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Path of the journal file for the current month.
|
||||
pub fn current_file(&mut self) -> PathBuf {
|
||||
let now = crate::installers::now_rfc3339();
|
||||
self.file_for(&month_of(&now))
|
||||
}
|
||||
|
||||
fn file_for(&mut self, month: &str) -> PathBuf {
|
||||
if let Some((m, p)) = &self.current {
|
||||
if m == month {
|
||||
return p.clone();
|
||||
}
|
||||
}
|
||||
let p = self.dir.join(file_name_for_month(month));
|
||||
self.current = Some((month.to_string(), p.clone()));
|
||||
p
|
||||
}
|
||||
|
||||
/// Append one event as a single JSON line; returns the file written to.
|
||||
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
|
||||
let month = month_of(&event.ts);
|
||||
let path = self.file_for(&month);
|
||||
append_to(&path, event)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_roundtrip() {
|
||||
let ev = Event::now(EventKind::Start)
|
||||
.with_agent("claude-code")
|
||||
.with_pid(42)
|
||||
.with_session("s1")
|
||||
.with_env_keys(vec!["OPENAI_KEY".to_string()])
|
||||
.with_args(vec!["--dangerously-skip-permissions".to_string()]);
|
||||
let json = serde_json::to_string(&ev).unwrap();
|
||||
assert!(!json.contains('\n'));
|
||||
let back: Event = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, ev);
|
||||
assert_eq!(back.agent.as_deref(), Some("claude-code"));
|
||||
assert_eq!(back.pid, Some(42));
|
||||
// Absent optional fields are omitted from the line.
|
||||
assert!(!json.contains("duration_s"));
|
||||
assert!(!json.contains("reason"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_keys_are_sorted_and_never_values() {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("ZEBRA".to_string(), "secret-z".to_string());
|
||||
env.insert("ALPHA".to_string(), "secret-a".to_string());
|
||||
let keys = env_keys(&env);
|
||||
assert_eq!(keys, vec!["ALPHA".to_string(), "ZEBRA".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_helpers() {
|
||||
assert_eq!(month_of("2026-08-16T13:49:11Z"), "202608");
|
||||
assert_eq!(file_name_for_month("202608"), "events-202608.jsonl");
|
||||
assert_eq!(
|
||||
month_from_file_name("events-202608.jsonl"),
|
||||
Some("202608".to_string())
|
||||
);
|
||||
assert_eq!(month_from_file_name("state.json"), None);
|
||||
assert_eq!(month_from_file_name("events-202608.jsonl.bak"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut log = EventLog::new(dir.path());
|
||||
let ev = Event::now(EventKind::Run)
|
||||
.with_agent("aider")
|
||||
.with_exit_code(0)
|
||||
.with_duration(48);
|
||||
let path = log.append(&ev).unwrap();
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|n| n.to_str()),
|
||||
Some(file_name_for_month(&month_of(&ev.ts)).as_str())
|
||||
);
|
||||
let read = parse_file(&path);
|
||||
assert_eq!(read.len(), 1);
|
||||
assert_eq!(read[0], ev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_lines_are_skipped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut log = EventLog::new(dir.path());
|
||||
let ev = Event::now(EventKind::Doctor);
|
||||
log.append(&ev).unwrap();
|
||||
// Corrupt the file with garbage lines around a valid one.
|
||||
let path = log.current_file();
|
||||
let mut text = std::fs::read_to_string(&path).unwrap();
|
||||
text.push_str("ceci n'est pas du json
|
||||
");
|
||||
text.push_str("
|
||||
");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let read = parse_file(&path);
|
||||
assert_eq!(read.len(), 1);
|
||||
assert_eq!(read[0].kind, EventKind::Doctor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_events_spans_months_and_respects_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Two monthly files, one unrelated file.
|
||||
std::fs::write(
|
||||
dir.path().join("events-202607.jsonl"),
|
||||
r#"{"ts":"2026-07-10T10:00:00Z","kind":"install","agent":"a"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("events-202608.jsonl"),
|
||||
concat!(
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"b"}"#,
|
||||
"\n",
|
||||
r#"{"ts":"2026-08-02T10:00:00Z","kind":"stop","agent":"b"}"#,
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("state.json"), "{}").unwrap();
|
||||
|
||||
let files = journal_files(dir.path());
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files[0].to_string_lossy().ends_with("events-202607.jsonl"));
|
||||
|
||||
let all = read_events(dir.path(), 0);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].ts, "2026-07-10T10:00:00Z");
|
||||
assert_eq!(all[2].ts, "2026-08-02T10:00:00Z");
|
||||
|
||||
let limited = read_events(dir.path(), 2);
|
||||
assert_eq!(limited.len(), 2);
|
||||
assert_eq!(limited[0].ts, "2026-08-01T10:00:00Z");
|
||||
assert_eq!(limited[1].ts, "2026-08-02T10:00:00Z");
|
||||
}
|
||||
}
|
||||
+1664
File diff suppressed because it is too large
Load Diff
+332
@@ -0,0 +1,332 @@
|
||||
//! Structured command history: one JSONL file per REPL session under
|
||||
//! history/<session>.jsonl. Replaces the flat history.txt (imported once).
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
/// RFC 3339 timestamp.
|
||||
pub ts: String,
|
||||
pub cmd: String,
|
||||
/// "am" (agent-manager command) or "shell" (system gateway command).
|
||||
#[serde(default = "kind_am")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
fn kind_am() -> String {
|
||||
"am".to_string()
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
pub fn now(cmd: impl Into<String>) -> Self {
|
||||
HistoryEntry {
|
||||
ts: crate::installers::now_rfc3339(),
|
||||
cmd: cmd.into(),
|
||||
kind: "am".to_string(),
|
||||
cwd: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
|
||||
self.kind = kind.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_exit_code(mut self, code: Option<i32>) -> Self {
|
||||
self.exit_code = code;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_duration_ms(mut self, ms: u64) -> Self {
|
||||
self.duration_ms = Some(ms);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_agent(mut self, agent: Option<String>) -> Self {
|
||||
self.agent = agent;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory holding the per-session history files.
|
||||
pub fn dir(app: &App) -> PathBuf {
|
||||
app.events_dir().join("history")
|
||||
}
|
||||
|
||||
/// Append one entry to the session file of a REPL session.
|
||||
pub fn record(app: &App, session: &str, entry: &HistoryEntry) -> Result<()> {
|
||||
let d = dir(app);
|
||||
std::fs::create_dir_all(&d).ok();
|
||||
let path = d.join(format!("{session}.jsonl"));
|
||||
let line = serde_json::to_string(entry)?;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("cannot open history file {}", path.display()))?;
|
||||
use std::io::Write;
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot migration: import the flat history.txt into the structured
|
||||
/// directory. Returns the number of imported entries.
|
||||
pub fn migrate(app: &App) -> Result<usize> {
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
if !txt.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let d = dir(app);
|
||||
let already = d.join("migrated.jsonl").exists();
|
||||
if already {
|
||||
return Ok(0);
|
||||
}
|
||||
let text = std::fs::read_to_string(&txt)
|
||||
.with_context(|| format!("cannot read {}", txt.display()))?;
|
||||
let mut count = 0;
|
||||
let mut previous: Option<String> = None;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Déduplication des répétitions consécutives (quick win #13).
|
||||
if previous.as_deref() == Some(trimmed) {
|
||||
continue;
|
||||
}
|
||||
previous = Some(trimmed.to_string());
|
||||
let entry = HistoryEntry::now(trimmed.to_string())
|
||||
.with_kind("am".to_string())
|
||||
.with_cwd(None);
|
||||
record(app, "migrated", &entry)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Expand a history reference against the most recent entries
|
||||
/// (most recent first): !! (last), !N (the Nth most recent), !prefix (the
|
||||
/// most recent command starting with prefix) and ^old^new (substitution in
|
||||
/// the last command). Returns None when the line is not a reference.
|
||||
pub fn expand(entries: &[HistoryEntry], line: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "!!" {
|
||||
return entries.first().map(|e| e.cmd.clone());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix('!') {
|
||||
if !rest.is_empty() {
|
||||
if let Ok(n) = rest.parse::<usize>() {
|
||||
if n >= 1 {
|
||||
return entries.get(n - 1).map(|e| e.cmd.clone());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
return entries
|
||||
.iter()
|
||||
.find(|e| e.cmd.starts_with(rest))
|
||||
.map(|e| e.cmd.clone());
|
||||
}
|
||||
}
|
||||
if let Some(body) = trimmed.strip_prefix('^') {
|
||||
if let Some((old, new)) = body.split_once('^') {
|
||||
if !old.is_empty() {
|
||||
let last = entries.first()?;
|
||||
return Some(last.cmd.replacen(old, new, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// History files of a directory, oldest first.
|
||||
pub fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Read all entries across sessions, most recent first. Corrupt lines are
|
||||
/// skipped silently.
|
||||
pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
|
||||
let d = dir(app);
|
||||
let mut out: Vec<HistoryEntry> = Vec::new();
|
||||
for f in files(&d).iter().rev() {
|
||||
if let Ok(text) = std::fs::read_to_string(f) {
|
||||
let mut entries: Vec<HistoryEntry> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
|
||||
.collect();
|
||||
entries.reverse(); // newest first within one file
|
||||
out.extend(entries);
|
||||
if limit > 0 && out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if limit > 0 {
|
||||
out.truncate(limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the session id from a history file name ("20260815_143926.jsonl").
|
||||
pub fn session_of_file(path: &Path) -> Option<String> {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.trim_end_matches(".jsonl").to_string())
|
||||
}
|
||||
|
||||
/// Corrupt-history guard: a session file that cannot be parsed is reported,
|
||||
/// never fatal.
|
||||
pub fn validate_file(path: &Path) -> Result<()> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read history file {}", path.display()))?;
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
serde_json::from_str::<HistoryEntry>(line).map_err(|e| {
|
||||
anyhow!("history file {} line {} is corrupt: {e}", path.display(), i + 1)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(tag: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
let _ = tag;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_read_back() {
|
||||
let app = test_app("hist");
|
||||
record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
|
||||
record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("ls").with_kind("shell".to_string()).with_duration_ms(12),
|
||||
)
|
||||
.unwrap();
|
||||
let all = read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "ls", "most recent first");
|
||||
assert_eq!(all[0].kind, "shell");
|
||||
assert_eq!(all[0].duration_ms, Some(12));
|
||||
assert_eq!(all[1].cmd, "install jcode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_imports_flat_file_once() {
|
||||
let app = test_app("mig");
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
start group:dev
|
||||
|
||||
").unwrap();
|
||||
assert_eq!(migrate(&app).unwrap(), 2);
|
||||
assert_eq!(migrate(&app).unwrap(), 0, "second run is a no-op");
|
||||
let all = read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "start group:dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_lines_are_skipped() {
|
||||
let app = test_app("corrupt");
|
||||
record(&app, "s1", &HistoryEntry::now("list")).unwrap();
|
||||
// Append garbage to the session file.
|
||||
let d = dir(&app);
|
||||
let f = d.join("s1.jsonl");
|
||||
let mut text = std::fs::read_to_string(&f).unwrap();
|
||||
text.push_str("not json at all
|
||||
");
|
||||
std::fs::write(&f, text).unwrap();
|
||||
let all = read_all(&app, 0);
|
||||
assert_eq!(all.len(), 1);
|
||||
assert!(validate_file(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_of_file_extracts_id() {
|
||||
assert_eq!(
|
||||
session_of_file(Path::new("20260815_143926.jsonl")),
|
||||
Some("20260815_143926".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_history_references() {
|
||||
let entries = vec![
|
||||
HistoryEntry::now("install jcode"),
|
||||
HistoryEntry::now("start group:dev"),
|
||||
HistoryEntry::now("list --all"),
|
||||
];
|
||||
assert_eq!(expand(&entries, "!!").as_deref(), Some("install jcode"));
|
||||
assert_eq!(expand(&entries, "!2").as_deref(), Some("start group:dev"));
|
||||
assert_eq!(expand(&entries, "!9"), None, "out of range");
|
||||
assert_eq!(expand(&entries, "!li").as_deref(), Some("list --all"));
|
||||
assert_eq!(
|
||||
expand(&entries, "^jcode^aider").as_deref(),
|
||||
Some("install aider")
|
||||
);
|
||||
assert_eq!(expand(&entries, "plain line"), None);
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
//! Hooks: user-defined commands run around agent lifecycle events
|
||||
//! (on_install, on_start, on_stop, on_update). Global settings.hooks run
|
||||
//! first, then the hooks of the project profile containing the cwd.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::runner::SystemRunner;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Run every hook registered for an event, in order. Failures are warnings
|
||||
/// and never break the main command; --dry-run announces them without
|
||||
/// executing anything.
|
||||
pub fn run_hooks(app: &App, hook: &str, cwd: &Path) {
|
||||
let mut cmds: Vec<String> = Vec::new();
|
||||
if let Some(global) = app.config.settings.hooks.get(hook) {
|
||||
cmds.extend(global.iter().cloned());
|
||||
}
|
||||
if let Some((_, profile)) = crate::projects::profile_for(app, cwd) {
|
||||
if let Some(list) = profile.hooks.get(hook) {
|
||||
cmds.extend(list.iter().cloned());
|
||||
}
|
||||
}
|
||||
if cmds.is_empty() {
|
||||
return;
|
||||
}
|
||||
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
|
||||
for cmd in &cmds {
|
||||
let tokens = shell_words::split(cmd).unwrap_or_else(|_| vec![cmd.clone()]);
|
||||
if tokens.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prog = tokens[0].clone();
|
||||
let args: Vec<String> = tokens[1..].to_vec();
|
||||
app.log.info(&format!("hook {hook}: {cmd}"));
|
||||
if let Err(e) = crate::installers::sh(&runner, &prog, &args, &BTreeMap::new(), Some(cwd)) {
|
||||
app.log.warn(&format!("hook {hook} failed: {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn app_with(dir: &std::path::Path) -> App {
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_runs_and_writes_marker() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let marker = dir.join("marker.txt");
|
||||
let mut app = app_with(&dir);
|
||||
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
|
||||
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
|
||||
run_hooks(&app, "on_start", &dir);
|
||||
assert!(marker.exists(), "hook a ecrit le marqueur");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_hook_warns_without_panicking() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let mut app = app_with(&dir);
|
||||
app.config.settings.hooks.insert("on_start".to_string(), vec!["sh -c exit 3".to_string()]);
|
||||
run_hooks(&app, "on_start", &dir); // ne panique pas
|
||||
run_hooks(&app, "on_update", &dir); // aucun hook : no-op
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_announces_without_executing() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let marker = dir.join("marker2.txt");
|
||||
let mut app = app_with(&dir);
|
||||
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
|
||||
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
|
||||
app.cli.dry_run = true;
|
||||
run_hooks(&app, "on_start", &dir);
|
||||
assert!(!marker.exists(), "dry-run ne doit rien executer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_hooks_parse_from_yaml() {
|
||||
let cfg: crate::config::Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n hooks:\n on_start: [\"sh -c echo hi\"]\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cfg.settings.hooks["on_start"],
|
||||
vec!["sh -c echo hi".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,9 @@ pub fn install(
|
||||
format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display())
|
||||
})?;
|
||||
}
|
||||
// Guard against installing something that is not an executable for this
|
||||
// platform (wrong architecture, an unextracted archive, ...).
|
||||
verify_executable(&dest_bin)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -88,6 +91,49 @@ pub fn install(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_executable_accepts_pe_and_rejects_elf() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pe = dir.path().join("a.exe");
|
||||
std::fs::write(&pe, b"MZwhatever").unwrap();
|
||||
assert!(verify_executable(&pe).is_ok());
|
||||
let elf = dir.path().join("b");
|
||||
std::fs::write(&elf, b"ELF").unwrap();
|
||||
let err = verify_executable(&elf).unwrap_err();
|
||||
assert!(err.to_string().contains("not a"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the downloaded file is an executable of this platform.
|
||||
/// Detects the classic Windows "not compatible" (error 216) and Linux
|
||||
/// "exec format error" failures before the agent is ever started.
|
||||
fn verify_executable(path: &std::path::Path) -> Result<()> {
|
||||
let mut head = [0u8; 4];
|
||||
let n = std::fs::File::open(path)
|
||||
.and_then(|mut f| std::io::Read::read(&mut f, &mut head))
|
||||
.unwrap_or(0);
|
||||
#[cfg(windows)]
|
||||
let ok = n >= 2 && &head[0..2] == b"MZ";
|
||||
#[cfg(target_os = "macos")]
|
||||
let ok = (n >= 4 && (&head[0..4] == b"\xcf\xfa\xed\xfe" || &head[0..4] == b"\xfe\xed\xfa\xcf"))
|
||||
|| (n >= 4 && &head[0..4] == b"\xca\xfe\xba\xbe");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let ok = n >= 4 && &head[0..4] == b"\x7fELF";
|
||||
if ok {
|
||||
return Ok(());
|
||||
}
|
||||
bail!(
|
||||
"downloaded file {} is not a {} executable (magic bytes {:02x?}) — the release asset is probably for another platform or is an installer/archive",
|
||||
path.display(),
|
||||
std::env::consts::OS,
|
||||
&head[..n]
|
||||
)
|
||||
}
|
||||
|
||||
/// Latest release tag (without the leading "v").
|
||||
pub fn latest(
|
||||
_app: &App,
|
||||
|
||||
+12
-1
@@ -97,7 +97,7 @@ pub fn install(
|
||||
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
|
||||
bins.push(dest);
|
||||
} else {
|
||||
let shim = bindir.join(exe_name(&bin_name));
|
||||
let shim = bindir.join(shim_name(&bin_name));
|
||||
write_shim(agent, &shim, &repo_dir)?;
|
||||
bins.push(shim);
|
||||
}
|
||||
@@ -132,6 +132,17 @@ pub fn install(
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
|
||||
/// File name of the shim for an agent: a .cmd script on Windows (a batch
|
||||
/// file renamed to .exe would fail with "Unsupported 16-Bit Application"),
|
||||
/// a plain executable name elsewhere.
|
||||
fn shim_name(bin_name: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
format!("{bin_name}.cmd")
|
||||
} else {
|
||||
bin_name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a small script that runs the agent from inside the repository
|
||||
/// directory (with node_modules/.bin on PATH for Node-based projects).
|
||||
fn write_shim(agent: &AgentDef, shim: &Path, repo_dir: &Path) -> Result<()> {
|
||||
|
||||
+33
-15
@@ -31,6 +31,10 @@ fn venv_python(venv: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// Shared implementation for pip (use_uv=false) and uv (use_uv=true).
|
||||
///
|
||||
/// The uv path creates the venv with 'uv venv': uv downloads and manages a
|
||||
/// Python interpreter itself when no working system Python exists, so uv
|
||||
/// installs do not depend on a system Python at all.
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
@@ -42,7 +46,6 @@ pub fn install(
|
||||
.package
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?;
|
||||
let python = find_python(runner)?;
|
||||
let root = install_root(app, &agent.name);
|
||||
let venv = root.join("venv");
|
||||
ensure_dir(app, &root)?;
|
||||
@@ -51,25 +54,26 @@ pub fn install(
|
||||
.with_context(|| format!("cannot remove {}", venv.display()))?;
|
||||
}
|
||||
let empty = BTreeMap::new();
|
||||
sh(
|
||||
runner,
|
||||
&python.display().to_string(),
|
||||
&[
|
||||
"-m".to_string(),
|
||||
"venv".to_string(),
|
||||
venv.display().to_string(),
|
||||
],
|
||||
&empty,
|
||||
None,
|
||||
)?;
|
||||
let vp = venv_python(&venv);
|
||||
let vbin = venv_bin_dir(&venv);
|
||||
if use_uv {
|
||||
let uv = require_tool(
|
||||
runner,
|
||||
"uv",
|
||||
"https://docs.astral.sh/uv — e.g. 'pip install uv'",
|
||||
)?;
|
||||
sh(
|
||||
runner,
|
||||
&uv.display().to_string(),
|
||||
&[
|
||||
"venv".into(),
|
||||
"--python".into(),
|
||||
"3.13".into(),
|
||||
venv.display().to_string(),
|
||||
],
|
||||
&empty,
|
||||
None,
|
||||
)?;
|
||||
let vp = venv_python(&venv);
|
||||
let vbin = venv_bin_dir(&venv);
|
||||
let mut args: Vec<String> = vec![
|
||||
"pip".into(),
|
||||
"install".into(),
|
||||
@@ -80,6 +84,20 @@ pub fn install(
|
||||
args.extend(method.args.iter().cloned());
|
||||
sh(runner, &uv.display().to_string(), &args, &method.env, None)?;
|
||||
} else {
|
||||
let python = find_python(runner)?;
|
||||
sh(
|
||||
runner,
|
||||
&python.display().to_string(),
|
||||
&[
|
||||
"-m".to_string(),
|
||||
"venv".to_string(),
|
||||
venv.display().to_string(),
|
||||
],
|
||||
&empty,
|
||||
None,
|
||||
)?;
|
||||
let vp = venv_python(&venv);
|
||||
let vbin = venv_bin_dir(&venv);
|
||||
let mut args: Vec<String> = vec![
|
||||
"-m".into(),
|
||||
"pip".into(),
|
||||
@@ -91,7 +109,7 @@ pub fn install(
|
||||
sh(runner, &vp.display().to_string(), &args, &method.env, None)?;
|
||||
}
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let mut bins = find_bins(&vbin, &expected);
|
||||
let mut bins = find_bins(&venv_bin_dir(&venv), &expected);
|
||||
if bins.is_empty() {
|
||||
bins = find_bins(&bin_dir(app), &expected);
|
||||
}
|
||||
|
||||
+24
@@ -16,16 +16,29 @@ pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod deps;
|
||||
pub mod doctor;
|
||||
pub mod download;
|
||||
pub mod events;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod hooks;
|
||||
pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
pub mod probe;
|
||||
pub mod projects;
|
||||
pub mod ps;
|
||||
pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod secrets;
|
||||
pub mod sessions;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod tables;
|
||||
pub mod theme;
|
||||
pub mod toolchain;
|
||||
pub mod version;
|
||||
|
||||
@@ -33,6 +46,17 @@ use clap::Parser;
|
||||
|
||||
/// CLI entry point used by the am binary. Returns the process exit code.
|
||||
pub fn main_entry() -> i32 {
|
||||
// Enable ANSI escape sequences on Windows consoles (best effort): the
|
||||
// colored crate and the theme palettes both emit raw VT codes.
|
||||
#[cfg(windows)]
|
||||
let _ = colored::control::set_virtual_terminal(true);
|
||||
|
||||
// Nushell-style help: 'am -h', 'am install -h', 'am ls --help', ... all
|
||||
// show the custom help before clap gets a chance to parse the line.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(code) = help::intercept(&args) {
|
||||
return code;
|
||||
}
|
||||
let cli = cli::Cli::parse();
|
||||
match app::App::from_cli(cli) {
|
||||
Ok(app) => match commands::execute(&app) {
|
||||
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
//! Nushell-inspired navigation helpers: a boxed table listing of files and
|
||||
//! directories (ls), and directory/path completion for Tab.
|
||||
|
||||
use crate::tables::{Cell, DataTable};
|
||||
use anyhow::{Context, Result};
|
||||
use colored::Colorize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Human-readable byte size, like Nushell ("512 B", "1.2 KB", "3.4 MB").
|
||||
pub fn format_size(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = 1024.0 * 1024.0;
|
||||
const GB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
let b = bytes as f64;
|
||||
if b < KB {
|
||||
format!("{bytes} B")
|
||||
} else if b < MB {
|
||||
format!("{:.1} KB", b / KB)
|
||||
} else if b < GB {
|
||||
format!("{:.1} MB", b / MB)
|
||||
} else {
|
||||
format!("{:.1} GB", b / GB)
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive size of a directory, tightly bounded: beyond the entry cap the
|
||||
/// size is reported as unknown (None). The cap is deliberately small so a
|
||||
/// listing stays snappy even when it contains huge trees.
|
||||
pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
const CAP: usize = 500;
|
||||
let mut total: u64 = 0;
|
||||
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
|
||||
let mut visited: usize = 0;
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = std::fs::read_dir(&dir).ok()?;
|
||||
for entry in rd.flatten() {
|
||||
visited += 1;
|
||||
if visited > CAP {
|
||||
return None;
|
||||
}
|
||||
let p = entry.path();
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.is_dir() => stack.push(p),
|
||||
Ok(m) if m.is_file() => {
|
||||
total = total.saturating_add(m.len());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
fn modified_text(meta: &std::fs::Metadata) -> String {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<chrono::Local> = t.into();
|
||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn paint(s: &str, color: bool, style: u8) -> String {
|
||||
if !color {
|
||||
return s.to_string();
|
||||
}
|
||||
match style {
|
||||
1 => s.blue().bold().to_string(), // directory
|
||||
2 => s.green().to_string(), // executable
|
||||
3 => s.cyan().to_string(), // symlink
|
||||
4 => s.red().to_string(), // broken symlink
|
||||
_ => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = meta;
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.as_deref(),
|
||||
Some("exe") | Some("bat") | Some("cmd") | Some("com")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One ls row: (sort key, cells). A single symlink_metadata call per entry
|
||||
/// (the modification time comes from the same metadata).
|
||||
fn row_for(p: &Path, color: bool) -> Option<(String, Vec<Cell>)> {
|
||||
let meta = std::fs::symlink_metadata(p).ok()?;
|
||||
let file_type = meta.file_type();
|
||||
let name = p.file_name().and_then(|n| n.to_str())?.to_string();
|
||||
let (kind, style) = if file_type.is_symlink() {
|
||||
("symlink", if p.exists() { 3 } else { 4 })
|
||||
} else if file_type.is_dir() {
|
||||
("dir", 1)
|
||||
} else if is_executable(p, &meta) {
|
||||
("file", 2)
|
||||
} else {
|
||||
("file", 0)
|
||||
};
|
||||
let (size_text, size_bytes) = if file_type.is_dir() {
|
||||
match dir_size(p) {
|
||||
Some(s) => (format_size(s), Some(s)),
|
||||
None => ("—".to_string(), None),
|
||||
}
|
||||
} else {
|
||||
(format_size(meta.len()), Some(meta.len()))
|
||||
};
|
||||
let mut display = name.clone();
|
||||
if file_type.is_dir() {
|
||||
display.push('/');
|
||||
} else if file_type.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(p) {
|
||||
display.push_str(&format!(" -> {}", target.display()));
|
||||
}
|
||||
}
|
||||
Some((
|
||||
name.to_lowercase(),
|
||||
vec![
|
||||
Cell::str(paint(&display, color, style)),
|
||||
Cell::str(kind.to_string()),
|
||||
Cell::bytes(size_bytes.unwrap_or(0), size_text),
|
||||
Cell::str(modified_text(&meta)),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the ls table (columns: name, type, size, modified). Directories
|
||||
/// come first, then alphabetical order.
|
||||
///
|
||||
/// Rows are built in parallel: recursive directory sizes dominate the cost,
|
||||
/// so a listing with many subdirectories stays fast (like Nushell, which
|
||||
/// also parallelizes its listing).
|
||||
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
|
||||
let rd = std::fs::read_dir(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(8);
|
||||
let chunk_size = entries.len().div_ceil(threads).max(1);
|
||||
let rows: Vec<(String, Vec<Cell>)> = std::thread::scope(|scope| {
|
||||
let mut handles = Vec::new();
|
||||
for chunk in entries.chunks(chunk_size) {
|
||||
let chunk: Vec<PathBuf> = chunk.to_vec();
|
||||
handles.push(scope.spawn(move || {
|
||||
chunk
|
||||
.into_iter()
|
||||
.filter_map(|p| row_for(&p, color))
|
||||
.collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for handle in handles {
|
||||
if let Ok(mut rows) = handle.join() {
|
||||
out.append(&mut rows);
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
|
||||
// Directories first, then alphabetical.
|
||||
let mut rows = rows;
|
||||
rows.sort_by(|a, b| {
|
||||
let a_dir = a.1[1].text == "dir";
|
||||
let b_dir = b.1[1].text == "dir";
|
||||
b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0))
|
||||
});
|
||||
Ok(DataTable {
|
||||
columns: vec![
|
||||
"name".into(),
|
||||
"type".into(),
|
||||
"size".into(),
|
||||
"modified".into(),
|
||||
],
|
||||
rows: rows.into_iter().map(|(_, r)| r).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a Nushell-style boxed table of the entries of a path (unbounded
|
||||
/// width — used by tests; the REPL renders with the terminal width).
|
||||
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
Ok(ls_table(path, color)?.render(color, 200))
|
||||
}
|
||||
|
||||
/// Completion candidates for a path fragment typed after cd or ls.
|
||||
///
|
||||
/// The last segment is matched against the entries of the base directory;
|
||||
/// directory candidates end with a separator so the next Tab dives one level
|
||||
/// deeper, like Nushell. With dirs_only, files are excluded (cd).
|
||||
pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec<String> {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let alt = if sep == '\\' { '/' } else { '\\' };
|
||||
let idx = input.rfind(|c| c == sep || c == alt);
|
||||
let (base_part, stem) = match idx {
|
||||
Some(i) => (&input[..=i], &input[i + 1..]),
|
||||
None => ("", input),
|
||||
};
|
||||
let use_sep = if input.contains(alt) && !input.contains(sep) {
|
||||
alt
|
||||
} else {
|
||||
sep
|
||||
};
|
||||
let base: PathBuf = if base_part.starts_with('~') {
|
||||
crate::config::expand_path(base_part)
|
||||
} else if base_part.is_empty() {
|
||||
base_dir.to_path_buf()
|
||||
} else {
|
||||
PathBuf::from(base_part)
|
||||
};
|
||||
|
||||
let mut names: Vec<(String, bool)> = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(&base) {
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
let is_dir = p.is_dir();
|
||||
if dirs_only && !is_dir {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(stem) {
|
||||
continue;
|
||||
}
|
||||
names.push((name.to_string(), is_dir));
|
||||
}
|
||||
}
|
||||
names.sort_by(|a, b| {
|
||||
b.1.cmp(&a.1)
|
||||
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
|
||||
});
|
||||
names
|
||||
.into_iter()
|
||||
.map(|(name, is_dir)| {
|
||||
let mut full = format!("{base_part}{name}");
|
||||
if is_dir {
|
||||
full.push(use_sep);
|
||||
}
|
||||
full
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_sizes() {
|
||||
assert_eq!(format_size(0), "0 B");
|
||||
assert_eq!(format_size(512), "512 B");
|
||||
assert_eq!(format_size(2048), "2.0 KB");
|
||||
assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
|
||||
assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_size_is_bounded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let big = dir.path().join("big");
|
||||
std::fs::create_dir_all(&big).unwrap();
|
||||
for i in 0..520 {
|
||||
std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap();
|
||||
}
|
||||
// Beyond the cap the size is unknown, and the walk stays cheap.
|
||||
assert_eq!(dir_size(&big), None);
|
||||
let small = dir.path().join("small");
|
||||
std::fs::create_dir_all(&small).unwrap();
|
||||
std::fs::write(small.join("a.txt"), "hello").unwrap();
|
||||
assert_eq!(dir_size(&small), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_directories_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src2")).unwrap();
|
||||
std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
|
||||
|
||||
let cands = path_candidates(dir.path(), "", true);
|
||||
assert!(cands.iter().any(|c| c == "src\\" || c == "src/"));
|
||||
assert!(cands.iter().any(|c| c == "src2\\" || c == "src2/"));
|
||||
assert!(!cands.iter().any(|c| c.contains("readme")));
|
||||
|
||||
let cands = path_candidates(dir.path(), "s", true);
|
||||
assert_eq!(cands.len(), 2, "{cands:?}");
|
||||
|
||||
// Files appear for ls completion.
|
||||
let cands = path_candidates(dir.path(), "", false);
|
||||
assert!(cands.iter().any(|c| c.contains("readme")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_nested_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src").join("commands")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src").join("installers")).unwrap();
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let prefix = format!("src{}", sep);
|
||||
let cands = path_candidates(dir.path(), &format!("src{}co", sep), true);
|
||||
assert!(cands.iter().any(|c| c == &format!("{prefix}commands{}", sep)));
|
||||
assert!(!cands.iter().any(|c| c.contains("installers")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_ls_table() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("subdir")).unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
|
||||
let table = render_ls(dir.path(), false).unwrap();
|
||||
assert!(table.contains("╭"));
|
||||
assert!(table.contains("╰"));
|
||||
assert!(table.contains("name"));
|
||||
assert!(table.contains("type"));
|
||||
assert!(table.contains("modified"));
|
||||
assert!(table.contains("subdir/"));
|
||||
assert!(table.contains("file.txt"));
|
||||
assert!(table.contains("dir"));
|
||||
assert!(table.contains("file"));
|
||||
assert!(table.contains("5 B"));
|
||||
// Every line of the table has the same width.
|
||||
let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}");
|
||||
}
|
||||
}
|
||||
+270
-13
@@ -143,6 +143,153 @@ impl Logger {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared boxed outputs (the Nushell-style "white frame")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Truncate a string to max visible chars with an ellipsis.
|
||||
pub fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut cut: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
cut.push('…');
|
||||
cut
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a boxed Nushell-style table (white frame, like ls) from plain rows
|
||||
/// and render it, fitted to the terminal width and the active theme.
|
||||
pub fn boxed_table(
|
||||
headers: Vec<&str>,
|
||||
rows: Vec<Vec<String>>,
|
||||
theme: &crate::theme::Theme,
|
||||
color: bool,
|
||||
) -> String {
|
||||
use crate::tables::{Cell, DataTable};
|
||||
let table = DataTable {
|
||||
columns: headers.into_iter().map(|h| h.to_string()).collect(),
|
||||
rows: rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_iter().map(Cell::str).collect())
|
||||
.collect(),
|
||||
};
|
||||
table.render_themed(theme, color, terminal_width().unwrap_or(120))
|
||||
}
|
||||
|
||||
/// Render a two-column key/value box (no header row), like the version
|
||||
/// table. Keys use the theme key style, values the value style.
|
||||
pub fn kv_table(
|
||||
rows: &[(String, String)],
|
||||
theme: &crate::theme::Theme,
|
||||
color: bool,
|
||||
) -> String {
|
||||
let key_w = rows
|
||||
.iter()
|
||||
.map(|(k, _)| visible_len(k))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let val_w = rows
|
||||
.iter()
|
||||
.map(|(_, v)| visible_len(v))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let line = format!(
|
||||
"{left}{}{mid}{}{right}",
|
||||
"─".repeat(key_w + 2),
|
||||
"─".repeat(val_w + 2)
|
||||
);
|
||||
if color {
|
||||
theme.frame(&line)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
};
|
||||
let paint = |s: &str, color: bool, style: fn(&crate::theme::Theme, &str) -> String| {
|
||||
if color && !s.contains('\u{1b}') {
|
||||
style(theme, s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let bar = if color {
|
||||
theme.frame("│")
|
||||
} else {
|
||||
"│".to_string()
|
||||
};
|
||||
let mut out = String::new();
|
||||
out.push_str(&border('╭', '┬', '╮'));
|
||||
out.push('\n');
|
||||
for (k, v) in rows {
|
||||
let key = paint(k, color, crate::theme::Theme::key);
|
||||
let val = paint(v, color, crate::theme::Theme::val);
|
||||
out.push_str(&format!(
|
||||
"{bar} {} {bar} {} {bar}\n",
|
||||
pad(key.as_str(), key_w),
|
||||
pad(val.as_str(), val_w)
|
||||
));
|
||||
}
|
||||
out.push_str(&border('╰', '┴', '╯'));
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the lines of the boxed Tab-completion menu from (candidate,
|
||||
/// description) rows. Single column when no descriptions exist.
|
||||
pub fn completion_box(rows: &[(String, String)]) -> Vec<String> {
|
||||
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
|
||||
let name_w = rows
|
||||
.iter()
|
||||
.map(|(n, _)| n.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("name".chars().count());
|
||||
let desc_w = if has_desc {
|
||||
rows.iter()
|
||||
.map(|(_, d)| truncate(d, 40).chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("description".chars().count())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
line.push_str(&"─".repeat(name_w + 2));
|
||||
if has_desc {
|
||||
line.push(mid);
|
||||
line.push_str(&"─".repeat(desc_w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
out.push(border('╭', '┬', '╮'));
|
||||
if has_desc {
|
||||
out.push(format!(
|
||||
"│ {} │ {} │",
|
||||
pad("name", name_w),
|
||||
pad("description", desc_w)
|
||||
));
|
||||
out.push(border('├', '┼', '┤'));
|
||||
}
|
||||
for (name, desc) in rows {
|
||||
if has_desc {
|
||||
out.push(format!(
|
||||
"│ {} │ {} │",
|
||||
pad(name, name_w),
|
||||
pad(&truncate(desc, 40), desc_w)
|
||||
));
|
||||
} else {
|
||||
out.push(format!("│ {} │", pad(name, name_w)));
|
||||
}
|
||||
}
|
||||
out.push(border('╰', '┴', '╯'));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -213,22 +360,21 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// Color a status cell for display in tables.
|
||||
pub fn status_style(status: &str, color: bool) -> String {
|
||||
let s = status.to_string();
|
||||
/// Color a status cell for display in tables (theme-aware).
|
||||
pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||
if !color {
|
||||
return s;
|
||||
return status.to_string();
|
||||
}
|
||||
match status {
|
||||
"running" => s.green().bold().to_string(),
|
||||
"installed" => s.cyan().to_string(),
|
||||
"external" => s.yellow().to_string(),
|
||||
"not-installable" => s.red().to_string(),
|
||||
"not-installed" => s.dimmed().to_string(),
|
||||
"ok" => s.green().to_string(),
|
||||
"warn" => s.yellow().to_string(),
|
||||
"error" => s.red().bold().to_string(),
|
||||
_ => s,
|
||||
"running" => theme.acc(status),
|
||||
"installed" => theme.inf(status),
|
||||
"external" => theme.warn(status),
|
||||
"not-installable" => theme.err(status),
|
||||
"not-installed" => theme.dimmed(status),
|
||||
"ok" => theme.ok(status),
|
||||
"warn" => theme.warn(status),
|
||||
"error" => theme.err(status),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +385,42 @@ pub fn print_json<T: Serialize>(value: &T) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the terminal attached to stdout, in columns. Returns None when
|
||||
/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly
|
||||
/// for tests and scripts.
|
||||
pub fn terminal_width() -> Option<usize> {
|
||||
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
|
||||
if let Ok(w) = v.trim().parse::<usize>() {
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::System::Console::{
|
||||
GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO,
|
||||
};
|
||||
let handle = std::io::stdout().as_raw_handle() as isize;
|
||||
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
|
||||
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 {
|
||||
let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize;
|
||||
if width > 0 {
|
||||
return Some(width);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Ok(v) = std::env::var("COLUMNS") {
|
||||
if let Ok(w) = v.trim().parse::<usize>() {
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
@@ -271,3 +453,78 @@ fn pad(s: &str, width: usize) -> String {
|
||||
format!("{s}{}", " ".repeat(width - n))
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completion_box_with_descriptions_is_aligned() {
|
||||
let rows = vec![
|
||||
("list".to_string(), "list installed agents".to_string()),
|
||||
("status".to_string(), "show agent state".to_string()),
|
||||
];
|
||||
let lines = completion_box(&rows);
|
||||
assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom
|
||||
assert!(lines[0].starts_with('╭') && lines[0].contains('┬'));
|
||||
assert!(lines[0].ends_with('╮'));
|
||||
assert!(lines[1].contains("name") && lines[1].contains("description"));
|
||||
assert!(lines[2].starts_with('├'));
|
||||
assert!(lines[5].starts_with('╰'));
|
||||
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
|
||||
assert!(
|
||||
widths.iter().all(|w| *w == widths[0]),
|
||||
"unaligned: {lines:?}"
|
||||
);
|
||||
assert!(lines[3].contains("list") && lines[3].contains("list installed agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_box_without_descriptions_is_single_column() {
|
||||
let rows = vec![
|
||||
("src".to_string(), "".to_string()),
|
||||
("am.exe".to_string(), "".to_string()),
|
||||
];
|
||||
let lines = completion_box(&rows);
|
||||
assert_eq!(lines.len(), 4); // top, 2 rows, bottom
|
||||
assert!(!lines.iter().any(|l| l.contains("description")));
|
||||
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_table_is_aligned_with_and_without_colors() {
|
||||
let rows = vec![
|
||||
("version".to_string(), "0.2.13".to_string()),
|
||||
("build_os".to_string(), "windows-x86_64".to_string()),
|
||||
];
|
||||
for color in [false, true] {
|
||||
let text = kv_table(&rows, crate::theme::default_theme(), color);
|
||||
// Compare visible widths (ANSI codes must not shift columns).
|
||||
let widths: Vec<usize> = text
|
||||
.lines()
|
||||
.map(|l| strip_ansi(l).chars().count())
|
||||
.collect();
|
||||
assert!(
|
||||
widths.iter().all(|w| *w == widths[0]),
|
||||
"unaligned ({color}):\n{text}"
|
||||
);
|
||||
}
|
||||
// Color output really contains ANSI sequences.
|
||||
let colored = kv_table(&rows, crate::theme::default_theme(), true);
|
||||
assert!(colored.contains("\x1b["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxed_table_has_a_frame() {
|
||||
let text = boxed_table(
|
||||
vec!["NAME", "STATUS"],
|
||||
vec![vec!["claude-code".to_string(), "external".to_string()]],
|
||||
crate::theme::default_theme(),
|
||||
false,
|
||||
);
|
||||
assert!(text.contains("╭") && text.contains("╰"));
|
||||
assert!(text.contains("claude-code"));
|
||||
assert!(text.contains("NAME"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ pub fn spawn_background(
|
||||
program: &Path,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<u32> {
|
||||
let log = agent_log_path(app, agent_name);
|
||||
if app.dry_run() {
|
||||
@@ -121,6 +122,9 @@ pub fn spawn_background(
|
||||
.envs(env)
|
||||
.stdout(Stdio::from(file.try_clone()?))
|
||||
.stderr(Stdio::from(file));
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
//! Project aggregation: which agent works on which project. Derived from
|
||||
//! the event journal (project field), with a best-effort cache written to
|
||||
//! projects.json (refreshed by 'am doctor --fix').
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::ProjectProfile;
|
||||
use crate::events::{self, EventKind};
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct ProjectSummary {
|
||||
pub root: String,
|
||||
pub agents: Vec<String>,
|
||||
pub dominant: Option<String>,
|
||||
pub sessions: u64,
|
||||
pub launches: u64,
|
||||
pub total_duration_s: u64,
|
||||
pub last_activity: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache file (regenerated on read; deleted by doctor --fix).
|
||||
pub fn cache_file(app: &App) -> PathBuf {
|
||||
app.events_dir().join("projects.json")
|
||||
}
|
||||
|
||||
/// Aggregate the journal into per-project summaries, sorted by last
|
||||
/// activity (most recent first). Also refreshes the cache file.
|
||||
pub fn aggregate(app: &App) -> Vec<ProjectSummary> {
|
||||
let mut map: BTreeMap<String, ProjectSummary> = BTreeMap::new();
|
||||
let mut agent_counts: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
|
||||
let mut sessions: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for ev in events::read_events(&app.events_dir(), 0) {
|
||||
let Some(project) = ev.project else {
|
||||
continue;
|
||||
};
|
||||
let s = map
|
||||
.entry(project.clone())
|
||||
.or_insert_with(|| ProjectSummary {
|
||||
root: project.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(a) = ev.agent {
|
||||
*agent_counts
|
||||
.entry(project.clone())
|
||||
.or_default()
|
||||
.entry(a)
|
||||
.or_default() += 1;
|
||||
}
|
||||
if let Some(sid) = ev.session {
|
||||
sessions.entry(project.clone()).or_default().insert(sid);
|
||||
}
|
||||
match ev.kind {
|
||||
EventKind::Start | EventKind::Run => s.launches += 1,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(d) = ev.duration_s {
|
||||
s.total_duration_s += d;
|
||||
}
|
||||
if ev.ts > s.last_activity.clone().unwrap_or_default() {
|
||||
s.last_activity = Some(ev.ts);
|
||||
}
|
||||
}
|
||||
for (p, s) in map.iter_mut() {
|
||||
s.agents = agent_counts
|
||||
.get(p)
|
||||
.map(|c| c.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
s.agents.sort();
|
||||
s.dominant = agent_counts
|
||||
.get(p)
|
||||
.and_then(|c| c.iter().max_by_key(|(_, n)| *n).map(|(a, _)| a.clone()));
|
||||
s.sessions = sessions.get(p).map(|set| set.len() as u64).unwrap_or(0);
|
||||
}
|
||||
let mut out: Vec<ProjectSummary> = map.into_values().collect();
|
||||
out.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
|
||||
// Best-effort cache (readable by scripts and backups).
|
||||
if let Ok(text) = serde_json::to_string_pretty(&out) {
|
||||
let _ = std::fs::write(cache_file(app), text);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The project profile whose root matches cwd (the longest root wins).
|
||||
pub fn profile_for<'a>(app: &'a App, cwd: &Path) -> Option<(&'a str, &'a ProjectProfile)> {
|
||||
let mut best: Option<(&str, &ProjectProfile)> = None;
|
||||
for (name, p) in &app.config.projects {
|
||||
let root = crate::config::expand_path(&p.root);
|
||||
let matches = cwd.starts_with(&root) || cwd == root;
|
||||
if matches {
|
||||
let better = best
|
||||
.map(|(_, b)| {
|
||||
let broot = crate::config::expand_path(&b.root);
|
||||
root.components().count() > broot.components().count()
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if better {
|
||||
best = Some((name.as_str(), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Default agent of the project containing cwd (issue #30 helper).
|
||||
pub fn default_agent_for_dir(app: &App, cwd: &Path) -> Option<String> {
|
||||
profile_for(app, cwd)
|
||||
.and_then(|(_, p)| p.default_agent.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn app_with_events(lines: &[&str]) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
let month = chrono::Utc::now().format("%Y%m");
|
||||
let path = dir.join(format!("events-{month}.jsonl"));
|
||||
let mut text = String::new();
|
||||
for l in lines {
|
||||
text.push_str(l);
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_projects_from_events() {
|
||||
let app = app_with_events(&[
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#,
|
||||
]);
|
||||
let out = aggregate(&app);
|
||||
assert_eq!(out.len(), 2);
|
||||
let api = out.iter().find(|s| s.root == "/home/x/api").unwrap();
|
||||
assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]);
|
||||
assert_eq!(api.dominant.as_deref(), Some("claude-code"));
|
||||
assert_eq!(api.sessions, 3);
|
||||
assert_eq!(api.launches, 3);
|
||||
assert_eq!(api.total_duration_s, 35);
|
||||
assert!(api.last_activity.is_some());
|
||||
assert!(cache_file(&app).exists(), "cache written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_matching_prefers_longest_root() {
|
||||
let app = app_with_events(&[]);
|
||||
let tmp = std::env::temp_dir();
|
||||
let api = tmp.join("p1-api");
|
||||
let sub = api.join("src");
|
||||
let _ = (api, sub);
|
||||
// Ce test est complete dans l'integration (config avec profils) ;
|
||||
// ici on verifie juste l'absence de profil par defaut.
|
||||
assert!(default_agent_for_dir(&app, &std::env::temp_dir()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! ps: Nushell-style process table.
|
||||
//!
|
||||
//! Windows uses the Toolhelp32 snapshot API (pid, ppid, name, threads, cpu
|
||||
//! time, working set); Unix reads /proc.
|
||||
|
||||
use crate::tables::{Cell, DataTable};
|
||||
|
||||
/// Columns: pid, ppid, name, cpu, mem, threads.
|
||||
fn columns() -> Vec<String> {
|
||||
vec![
|
||||
"pid".into(),
|
||||
"ppid".into(),
|
||||
"name".into(),
|
||||
"cpu".into(),
|
||||
"mem".into(),
|
||||
"threads".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the process table.
|
||||
pub fn ps_table() -> DataTable {
|
||||
#[cfg(windows)]
|
||||
let rows = windows_rows();
|
||||
#[cfg(unix)]
|
||||
let rows = unix_rows();
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
let rows = Vec::new();
|
||||
|
||||
let mut rows = rows;
|
||||
rows.sort_by(|a, b| {
|
||||
a[0]
|
||||
.num
|
||||
.unwrap_or(0.0)
|
||||
.partial_cmp(&b[0].num.unwrap_or(0.0))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
DataTable {
|
||||
columns: columns(),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows: Toolhelp32 snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_rows() -> Vec<Vec<Cell>> {
|
||||
use crate::nav::format_size;
|
||||
use std::mem::size_of;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::ProcessStatus::{
|
||||
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION,
|
||||
};
|
||||
|
||||
let mut rows: Vec<Vec<Cell>> = Vec::new();
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return rows;
|
||||
}
|
||||
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||
entry.dwSize = size_of::<PROCESSENTRY32W>() as u32;
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
let pid = entry.th32ProcessID;
|
||||
let ppid = entry.th32ParentProcessID;
|
||||
let threads = entry.cntThreads;
|
||||
let mut name = String::new();
|
||||
for &unit in entry.szExeFile.iter() {
|
||||
if unit == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(c) = char::from_u32(unit as u32) {
|
||||
name.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// CPU time and memory need a process handle; some system processes
|
||||
// refuse — those cells stay "—".
|
||||
let mut cpu = "—".to_string();
|
||||
let mut mem = "—".to_string();
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) };
|
||||
if handle != 0 {
|
||||
let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
K32GetProcessMemoryInfo(
|
||||
handle,
|
||||
&mut counters,
|
||||
size_of::<PROCESS_MEMORY_COUNTERS>() as u32,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
mem = format_size(counters.WorkingSetSize as u64);
|
||||
}
|
||||
let (mut created, mut exited, mut kernel, mut user): (
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
) = unsafe {
|
||||
(
|
||||
std::mem::zeroed(),
|
||||
std::mem::zeroed(),
|
||||
std::mem::zeroed(),
|
||||
std::mem::zeroed(),
|
||||
)
|
||||
};
|
||||
if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } != 0 {
|
||||
let ticks = (filetime_u64(&kernel) + filetime_u64(&user)) as f64 / 10_000_000.0;
|
||||
cpu = format!("{ticks:.0}s");
|
||||
}
|
||||
unsafe { CloseHandle(handle) };
|
||||
}
|
||||
|
||||
rows.push(vec![
|
||||
Cell::num(pid as f64, pid.to_string()),
|
||||
Cell::num(ppid as f64, ppid.to_string()),
|
||||
Cell::str(name),
|
||||
Cell::str(cpu),
|
||||
Cell::str(mem),
|
||||
Cell::num(threads as f64, threads.to_string()),
|
||||
]);
|
||||
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||
}
|
||||
unsafe { CloseHandle(snapshot) };
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn filetime_u64(ft: &windows_sys::Win32::Foundation::FILETIME) -> u64 {
|
||||
((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix: /proc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unix_rows() -> Vec<Vec<Cell>> {
|
||||
use crate::nav::format_size;
|
||||
let page_size = page_size();
|
||||
let mut rows: Vec<Vec<Cell>> = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return rows;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let Ok(pid) = name.parse::<f64>() else {
|
||||
continue;
|
||||
};
|
||||
let base = entry.path();
|
||||
let Ok(stat) = std::fs::read_to_string(base.join("stat")) else {
|
||||
continue;
|
||||
};
|
||||
// The comm field is in parentheses and may contain spaces.
|
||||
let Some(close) = stat.rfind(')') else {
|
||||
continue;
|
||||
};
|
||||
let comm = stat[stat.find('(').unwrap_or(0) + 1..close].to_string();
|
||||
let rest: Vec<&str> = stat[close + 2..].split_whitespace().collect();
|
||||
if rest.len() < 22 {
|
||||
continue;
|
||||
}
|
||||
// rest[0]=state, rest[1]=ppid, rest[11]=utime, rest[12]=stime,
|
||||
// rest[17]=num_threads, rest[21]=rss (pages).
|
||||
let ppid = rest[1].parse::<f64>().unwrap_or(0.0);
|
||||
let utime = rest[11].parse::<f64>().unwrap_or(0.0);
|
||||
let stime = rest[12].parse::<f64>().unwrap_or(0.0);
|
||||
let threads = rest[17].parse::<f64>().unwrap_or(0.0);
|
||||
let rss_pages = rest[21].parse::<u64>().unwrap_or(0);
|
||||
let cpu = (utime + stime) / 100.0;
|
||||
rows.push(vec![
|
||||
Cell::num(pid, pid.to_string()),
|
||||
Cell::num(ppid, ppid.to_string()),
|
||||
Cell::str(comm),
|
||||
Cell::str(format!("{cpu:.0}s")),
|
||||
Cell::str(format_size(rss_pages * page_size)),
|
||||
Cell::num(threads, threads.to_string()),
|
||||
]);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn page_size() -> u64 {
|
||||
// Standard page size; RSS in pages * 4096 is close enough for display.
|
||||
4096
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ps_produces_a_table() {
|
||||
let table = ps_table();
|
||||
assert_eq!(
|
||||
table.columns,
|
||||
vec!["pid", "ppid", "name", "cpu", "mem", "threads"]
|
||||
);
|
||||
assert!(!table.rows.is_empty(), "process table should not be empty");
|
||||
// Our own pid is in the table.
|
||||
let me = std::process::id().to_string();
|
||||
assert!(
|
||||
table.rows.iter().any(|r| r[0].text == me),
|
||||
"our pid should be listed"
|
||||
);
|
||||
// Rows have as many cells as columns.
|
||||
assert!(table.rows.iter().all(|r| r.len() == 6));
|
||||
}
|
||||
}
|
||||
+1560
-71
File diff suppressed because it is too large
Load Diff
+151
@@ -0,0 +1,151 @@
|
||||
//! Secrets: OS keyring storage (never plaintext in config, logs or env_keys).
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub trait SecretStore {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()>;
|
||||
fn get(&self, key: &str) -> Result<Option<String>>;
|
||||
fn remove(&self, key: &str) -> Result<bool>;
|
||||
fn list(&self) -> Result<Vec<String>>;
|
||||
}
|
||||
|
||||
const SERVICE: &str = "agent-manager";
|
||||
const INDEX_KEY: &str = "__index__";
|
||||
|
||||
/// OS keyring backend (Windows Credential Manager, macOS Keychain, Linux
|
||||
/// Secret Service when available).
|
||||
pub struct KeyringStore;
|
||||
|
||||
impl SecretStore for KeyringStore {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
let entry = keyring::Entry::new(SERVICE, key)?;
|
||||
entry.set_password(value).map_err(|e| anyhow!("keyring: {e}"))?;
|
||||
let mut idx = self.list().unwrap_or_default();
|
||||
if !idx.iter().any(|k| k == key) {
|
||||
idx.push(key.to_string());
|
||||
}
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&idx.join(","));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
let entry = keyring::Entry::new(SERVICE, key)?;
|
||||
match entry.get_password() {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&self, key: &str) -> Result<bool> {
|
||||
let entry = keyring::Entry::new(SERVICE, key)?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => Ok(true),
|
||||
Err(keyring::Error::NoEntry) => Ok(false),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
match index.get_password() {
|
||||
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The production store.
|
||||
pub fn store() -> KeyringStore {
|
||||
KeyringStore
|
||||
}
|
||||
|
||||
/// Key for an agent-scoped secret name: "claude-code/OPENAI_KEY".
|
||||
pub fn key_for(agent: &str, name: &str) -> String {
|
||||
format!("{agent}/{name}")
|
||||
}
|
||||
|
||||
/// Resolve "@secret" placeholders in an env map: a value equal to
|
||||
/// "@secret" pulls the secret named by the variable name for this agent.
|
||||
/// Returns human warnings (names only — values never leak).
|
||||
pub fn resolve_env_secrets(
|
||||
store: &dyn SecretStore,
|
||||
agent: &str,
|
||||
env: &mut BTreeMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let keys: Vec<String> = env.keys().cloned().collect();
|
||||
for k in keys {
|
||||
let placeholder = env.get(&k).map(|v| v == "@secret").unwrap_or(false);
|
||||
if !placeholder {
|
||||
continue;
|
||||
}
|
||||
let key = key_for(agent, &k);
|
||||
match store.get(&key) {
|
||||
Ok(Some(v)) => {
|
||||
env.insert(k.clone(), v);
|
||||
}
|
||||
Ok(None) => warnings.push(format!(
|
||||
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
|
||||
)),
|
||||
Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")),
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// In-memory store for tests.
|
||||
#[derive(Default)]
|
||||
pub struct MockStore {
|
||||
pub map: std::sync::Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl SecretStore for MockStore {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.map.lock().unwrap().insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
Ok(self.map.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
fn remove(&self, key: &str) -> Result<bool> {
|
||||
Ok(self.map.lock().unwrap().remove(key).is_some())
|
||||
}
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
Ok(self.map.lock().unwrap().keys().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mock_roundtrip() {
|
||||
let s = MockStore::default();
|
||||
s.set("claude-code/OPENAI_KEY", "sk-123").unwrap();
|
||||
assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap().as_deref(), Some("sk-123"));
|
||||
assert_eq!(s.list().unwrap(), vec!["claude-code/OPENAI_KEY".to_string()]);
|
||||
assert!(s.remove("claude-code/OPENAI_KEY").unwrap());
|
||||
assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_placeholders_and_warns() {
|
||||
let s = MockStore::default();
|
||||
s.set("aider/TOKEN", "secret-value").unwrap();
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("TOKEN".to_string(), "@secret".to_string());
|
||||
env.insert("PLAIN".to_string(), "kept".to_string());
|
||||
env.insert("MISSING".to_string(), "@secret".to_string());
|
||||
let warnings = resolve_env_secrets(&s, "aider", &mut env);
|
||||
assert_eq!(env.get("TOKEN").map(String::as_str), Some("secret-value"));
|
||||
assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept"));
|
||||
assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret"));
|
||||
assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}");
|
||||
assert!(!warnings[0].contains("secret-value"));
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
//! Session registry: one record per agent session (background start/stop
|
||||
//! cycles and interactive REPL sessions). sessions.json is an index derived
|
||||
//! from the event journal; it can be rebuilt with 'am doctor --fix'.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const SESSIONS_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionRecord {
|
||||
pub id: String,
|
||||
/// "agent" or "repl".
|
||||
#[serde(default = "kind_agent")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub agent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub project: Option<String>,
|
||||
/// RFC 3339 start timestamp.
|
||||
pub started_at: String,
|
||||
/// RFC 3339 end timestamp (None while running).
|
||||
#[serde(default)]
|
||||
pub ended_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub log: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Variable names only — never values.
|
||||
#[serde(default)]
|
||||
pub env_keys: Vec<String>,
|
||||
/// running | stopped | failed | interrupted
|
||||
#[serde(default = "status_running")]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn kind_agent() -> String {
|
||||
"agent".to_string()
|
||||
}
|
||||
|
||||
fn status_running() -> String {
|
||||
"running".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionsFile {
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub sessions: BTreeMap<String, SessionRecord>,
|
||||
}
|
||||
|
||||
/// Path of sessions.json (inside the state directory).
|
||||
pub fn file_path(app: &App) -> PathBuf {
|
||||
app.events_dir().join("sessions.json")
|
||||
}
|
||||
|
||||
pub fn load(app: &App) -> Result<SessionsFile> {
|
||||
let p = file_path(app);
|
||||
if !p.exists() {
|
||||
return Ok(SessionsFile {
|
||||
version: SESSIONS_VERSION,
|
||||
sessions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
let text = std::fs::read_to_string(&p)
|
||||
.with_context(|| format!("cannot read sessions file {}", p.display()))?;
|
||||
let mut sf: SessionsFile = serde_json::from_str(&text).map_err(|e| {
|
||||
anyhow!(
|
||||
"sessions file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
|
||||
p.display()
|
||||
)
|
||||
})?;
|
||||
if sf.version < SESSIONS_VERSION {
|
||||
sf.version = SESSIONS_VERSION;
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(sf)
|
||||
}
|
||||
|
||||
pub fn save(app: &App, sf: &SessionsFile) -> Result<()> {
|
||||
let p = file_path(app);
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = p.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(sf)?;
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, &p)
|
||||
.with_context(|| format!("cannot write sessions file {}", p.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a new agent session; returns its id.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start_agent(
|
||||
app: &App,
|
||||
agent: &str,
|
||||
pid: u32,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
args: Vec<String>,
|
||||
env_keys: Vec<String>,
|
||||
log: Option<String>,
|
||||
) -> Result<String> {
|
||||
let mut sf = load(app)?;
|
||||
let id = crate::repl::session_id();
|
||||
sf.sessions.insert(
|
||||
id.clone(),
|
||||
SessionRecord {
|
||||
id: id.clone(),
|
||||
kind: "agent".to_string(),
|
||||
agent: Some(agent.to_string()),
|
||||
pid: Some(pid),
|
||||
cwd,
|
||||
project,
|
||||
started_at: crate::installers::now_rfc3339(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
log,
|
||||
args,
|
||||
env_keys,
|
||||
status: "running".to_string(),
|
||||
},
|
||||
);
|
||||
save(app, &sf)?;
|
||||
let _ = app.state.bump_sessions();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Open a REPL session.
|
||||
pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
|
||||
let mut sf = load(app)?;
|
||||
sf.sessions.insert(
|
||||
id.to_string(),
|
||||
SessionRecord {
|
||||
id: id.to_string(),
|
||||
kind: "repl".to_string(),
|
||||
agent: None,
|
||||
pid: None,
|
||||
cwd,
|
||||
project: None,
|
||||
started_at: crate::installers::now_rfc3339(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
log: None,
|
||||
args: Vec::new(),
|
||||
env_keys: Vec::new(),
|
||||
status: "running".to_string(),
|
||||
},
|
||||
);
|
||||
save(app, &sf)?;
|
||||
let _ = app.state.bump_sessions();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the running session of an agent: the one matching the pid when
|
||||
/// given, otherwise the most recent one.
|
||||
pub fn finish_agent(app: &App, agent: &str, pid: Option<u32>, exit_code: i32, ok: bool) -> Result<()> {
|
||||
let mut sf = load(app)?;
|
||||
let mut candidates: Vec<String> = sf
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|(_, r)| r.kind == "agent" && r.status == "running" && r.agent.as_deref() == Some(agent))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| sf.sessions[b].started_at.cmp(&sf.sessions[a].started_at));
|
||||
let key = if let Some(p) = pid {
|
||||
candidates
|
||||
.iter()
|
||||
.find(|k| sf.sessions[*k].pid == Some(p))
|
||||
.cloned()
|
||||
.or_else(|| candidates.first().cloned())
|
||||
} else {
|
||||
candidates.first().cloned()
|
||||
};
|
||||
if let Some(k) = key {
|
||||
if let Some(r) = sf.sessions.get_mut(&k) {
|
||||
r.ended_at = Some(crate::installers::now_rfc3339());
|
||||
r.exit_code = Some(exit_code);
|
||||
r.status = if ok { "stopped" } else { "failed" }.to_string();
|
||||
}
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a REPL session.
|
||||
pub fn finish_repl(app: &App, id: &str) -> Result<()> {
|
||||
let mut sf = load(app)?;
|
||||
if let Some(r) = sf.sessions.get_mut(id) {
|
||||
r.ended_at = Some(crate::installers::now_rfc3339());
|
||||
r.exit_code = Some(0);
|
||||
r.status = "stopped".to_string();
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark running sessions whose process is dead as interrupted. Returns the
|
||||
/// number of sessions reconciled. REPL sessions (no pid) are left untouched:
|
||||
/// the current REPL has no pid, and only 'am sessions' triggers this.
|
||||
pub fn reconcile(app: &App) -> Result<usize> {
|
||||
let mut sf = load(app)?;
|
||||
let now = crate::installers::now_rfc3339();
|
||||
let mut changed = 0;
|
||||
for r in sf.sessions.values_mut() {
|
||||
if r.status != "running" {
|
||||
continue;
|
||||
}
|
||||
let dead = match r.pid {
|
||||
Some(p) => !crate::process::is_running(p),
|
||||
None => false,
|
||||
};
|
||||
if dead {
|
||||
r.status = "interrupted".to_string();
|
||||
r.ended_at = Some(now.clone());
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Duration of a session in seconds (elapsed when still running).
|
||||
pub fn duration_s(r: &SessionRecord) -> Option<u64> {
|
||||
let start = chrono::DateTime::parse_from_rfc3339(&r.started_at).ok()?;
|
||||
let end = match &r.ended_at {
|
||||
Some(e) => chrono::DateTime::parse_from_rfc3339(e).ok()?,
|
||||
None => chrono::Utc::now().into(),
|
||||
};
|
||||
let secs = end
|
||||
.with_timezone(&chrono::Utc)
|
||||
.signed_duration_since(start.with_timezone(&chrono::Utc))
|
||||
.num_seconds();
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(tag: &str) -> App {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
" confirm_before_run: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.path().join("agents");
|
||||
p.bin_dir = dir.path().join("agents").join("bin");
|
||||
p.log_dir = dir.path().join("logs");
|
||||
p.state_file = dir.path().join("state.json");
|
||||
p.probe_cache_file = dir.path().join("probe-cache.json");
|
||||
p.config_dir = Some(dir.path().join("config"));
|
||||
app.paths = p;
|
||||
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
|
||||
let _ = tag;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_session_lifecycle() {
|
||||
let app = test_app("sess");
|
||||
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], None).unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "running");
|
||||
assert_eq!(r.pid, Some(4242));
|
||||
assert_eq!(app.state.load().unwrap().sessions_count, 1);
|
||||
|
||||
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "stopped");
|
||||
assert_eq!(r.exit_code, Some(0));
|
||||
assert!(r.ended_at.is_some());
|
||||
assert!(duration_s(r).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_marks_dead_pid_interrupted() {
|
||||
let app = test_app("recon");
|
||||
let id = start_agent(&app, "aider", u32::MAX - 1, None, None, vec![], vec![], None).unwrap();
|
||||
let changed = reconcile(&app).unwrap();
|
||||
assert_eq!(changed, 1);
|
||||
let sf = load(&app).unwrap();
|
||||
assert_eq!(sf.sessions.get(&id).unwrap().status, "interrupted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_session_lifecycle() {
|
||||
let app = test_app("repl");
|
||||
start_repl(&app, "s1", None).unwrap();
|
||||
finish_repl(&app, "s1").unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
assert_eq!(sf.sessions.get("s1").unwrap().status, "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_matches_by_pid_first() {
|
||||
let app = test_app("multi");
|
||||
start_agent(&app, "jcode", 100, None, None, vec![], vec![], None).unwrap();
|
||||
start_agent(&app, "jcode", 200, None, None, vec![], vec![], None).unwrap();
|
||||
finish_agent(&app, "jcode", Some(200), 1, false).unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
let stopped: Vec<_> = sf
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|r| r.status == "stopped" || r.status == "failed")
|
||||
.collect();
|
||||
assert_eq!(stopped.len(), 1);
|
||||
assert_eq!(stopped[0].pid, Some(200));
|
||||
assert_eq!(stopped[0].exit_code, Some(1));
|
||||
assert_eq!(stopped[0].status, "failed");
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
//! System-shell gateway.
|
||||
//!
|
||||
//! The interactive prompt doubles as a gateway between the user and their
|
||||
//! system shell: every input that is not an am command is executed by the
|
||||
//! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...).
|
||||
//!
|
||||
//! The default shell is resolved at startup from, in order: the
|
||||
//! `settings.default_shell` configuration entry, the `SHELL` environment
|
||||
//! variable, `COMSPEC` (Windows), then the first shell found on PATH among
|
||||
//! the platform's preferred shells. `shell <name>` switches at runtime.
|
||||
|
||||
use crate::config::Config;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
/// One supported system shell and how to invoke it with a command line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShellSpec {
|
||||
/// Canonical short name ("pwsh", "cmd", "bash", ...).
|
||||
pub name: &'static str,
|
||||
/// Human-readable title ("PowerShell 7", "Command Prompt", ...).
|
||||
pub title: &'static str,
|
||||
/// Executable to spawn.
|
||||
pub program: &'static str,
|
||||
/// Arguments placed before the user's command line.
|
||||
pub args: &'static [&'static str],
|
||||
/// Executable probed on PATH to decide whether the shell is available.
|
||||
pub probe: &'static str,
|
||||
}
|
||||
|
||||
/// Every shell the gateway understands, in display order.
|
||||
pub static KNOWN_SHELLS: &[ShellSpec] = &[
|
||||
ShellSpec {
|
||||
name: "pwsh",
|
||||
title: "PowerShell 7",
|
||||
program: "pwsh",
|
||||
args: &["-NoLogo", "-NoProfile", "-Command"],
|
||||
probe: "pwsh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "powershell",
|
||||
title: "Windows PowerShell",
|
||||
program: "powershell",
|
||||
args: &["-NoLogo", "-NoProfile", "-Command"],
|
||||
probe: "powershell",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "cmd",
|
||||
title: "Command Prompt",
|
||||
program: "cmd",
|
||||
args: &["/C"],
|
||||
probe: "cmd",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "bash",
|
||||
title: "Bash",
|
||||
program: "bash",
|
||||
args: &["-c"],
|
||||
probe: "bash",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "zsh",
|
||||
title: "Zsh",
|
||||
program: "zsh",
|
||||
args: &["-c"],
|
||||
probe: "zsh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "fish",
|
||||
title: "Fish",
|
||||
program: "fish",
|
||||
args: &["-c"],
|
||||
probe: "fish",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "sh",
|
||||
title: "POSIX sh",
|
||||
program: "sh",
|
||||
args: &["-c"],
|
||||
probe: "sh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "nu",
|
||||
title: "Nushell",
|
||||
program: "nu",
|
||||
args: &["-c"],
|
||||
probe: "nu",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "elvish",
|
||||
title: "Elvish",
|
||||
program: "elvish",
|
||||
args: &["-c"],
|
||||
probe: "elvish",
|
||||
},
|
||||
];
|
||||
|
||||
/// Resolve a shell by canonical name or common alias, case-insensitively
|
||||
/// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...).
|
||||
pub fn find(name: &str) -> Option<&'static ShellSpec> {
|
||||
let mut n = name.trim().to_ascii_lowercase();
|
||||
if let Some(stripped) = n.strip_suffix(".exe") {
|
||||
n = stripped.to_string();
|
||||
}
|
||||
KNOWN_SHELLS.iter().find(|s| {
|
||||
s.name == n
|
||||
|| match s.name {
|
||||
"pwsh" => n == "powershell7" || n == "ps7",
|
||||
"powershell" => n == "ps",
|
||||
"cmd" => n == "dos" || n == "command" || n == "command-prompt",
|
||||
"sh" => n == "posix" || n == "dash",
|
||||
"nu" => n == "nushell",
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a shell executable can be found (or, for cmd on Windows, whether
|
||||
/// COMSPEC is set).
|
||||
pub fn probe_available(spec: &ShellSpec) -> bool {
|
||||
which::which(spec.probe).is_ok()
|
||||
|| (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some())
|
||||
}
|
||||
|
||||
/// Resolve the user's default shell from the configuration, environment
|
||||
/// variables, then the shells found on PATH. Always returns a shell.
|
||||
pub fn resolve_default(
|
||||
configured: Option<&str>,
|
||||
shell_env: Option<OsString>,
|
||||
comspec: Option<OsString>,
|
||||
) -> &'static ShellSpec {
|
||||
if let Some(s) = configured.and_then(find) {
|
||||
return s;
|
||||
}
|
||||
if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) {
|
||||
return s;
|
||||
}
|
||||
if let Some(s) = comspec.and_then(|v| from_env_path(&v)) {
|
||||
return s;
|
||||
}
|
||||
let preferred: &[&str] = if cfg!(windows) {
|
||||
&["pwsh", "powershell", "cmd"]
|
||||
} else {
|
||||
&["bash", "zsh", "fish", "sh"]
|
||||
};
|
||||
for name in preferred {
|
||||
if let Some(s) = find(name) {
|
||||
if probe_available(s) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Last resort: the platform's built-in shell.
|
||||
find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist")
|
||||
}
|
||||
|
||||
/// Extract a shell name from an environment value such as SHELL or COMSPEC.
|
||||
fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> {
|
||||
let text = value.to_string_lossy();
|
||||
let name = Path::new(text.as_ref())
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
find(&name)
|
||||
}
|
||||
|
||||
/// The gateway's runtime state: which shell executes system commands now,
|
||||
/// which one was detected as the user's default, and which are installed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellSession {
|
||||
/// Shell executing system commands right now.
|
||||
pub current: &'static ShellSpec,
|
||||
/// The shell detected as the user's default at startup.
|
||||
pub default: &'static ShellSpec,
|
||||
/// Shells found on this machine.
|
||||
pub available: Vec<&'static ShellSpec>,
|
||||
}
|
||||
|
||||
impl ShellSession {
|
||||
/// Build a session: detect the default shell and probe availability.
|
||||
pub fn start(config: &Config) -> Self {
|
||||
let default = resolve_default(
|
||||
config.settings.default_shell.as_deref(),
|
||||
std::env::var_os("SHELL"),
|
||||
std::env::var_os("COMSPEC"),
|
||||
);
|
||||
let available = KNOWN_SHELLS
|
||||
.iter()
|
||||
.filter(|s| probe_available(s))
|
||||
.collect();
|
||||
Self {
|
||||
current: default,
|
||||
default,
|
||||
available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active shell. Fails for unknown or unavailable shells.
|
||||
pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> {
|
||||
let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?;
|
||||
if !self.available.iter().any(|s| s.name == spec.name) {
|
||||
return Err(anyhow!(
|
||||
"shell '{}' is not available on this machine (available: {})",
|
||||
spec.name,
|
||||
self.available
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
self.current = spec;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// Whether the active shell is the user's default shell.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.current.name == self.default.name
|
||||
}
|
||||
|
||||
/// Execute one system command line in the current shell with inherited
|
||||
/// stdio, so interactive programs keep working. Returns the exit code.
|
||||
pub fn run(&self, line: &str) -> Result<i32> {
|
||||
let status = ProcessCommand::new(self.current.program)
|
||||
.args(self.current.args)
|
||||
.arg(line)
|
||||
.status()
|
||||
.map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_resolves_aliases() {
|
||||
assert_eq!(find("pwsh").unwrap().name, "pwsh");
|
||||
assert_eq!(find("PowerShell7").unwrap().name, "pwsh");
|
||||
assert_eq!(find("ps").unwrap().name, "powershell");
|
||||
assert_eq!(find("cmd.exe").unwrap().name, "cmd");
|
||||
assert_eq!(find("CMD").unwrap().name, "cmd");
|
||||
assert_eq!(find("nushell").unwrap().name, "nu");
|
||||
assert_eq!(find("dash").unwrap().name, "sh");
|
||||
assert!(find("blorp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_default_prefers_configured_shell() {
|
||||
let s = resolve_default(Some("zsh"), None, None);
|
||||
assert_eq!(s.name, "zsh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_default_uses_environment_values() {
|
||||
// SHELL wins over COMSPEC when the configured value is absent.
|
||||
let s = resolve_default(
|
||||
None,
|
||||
Some(OsString::from("/usr/bin/bash")),
|
||||
Some(OsString::from("C:/Windows/system32/cmd.exe")),
|
||||
);
|
||||
assert_eq!(s.name, "bash");
|
||||
// COMSPEC alone is honored.
|
||||
let s = resolve_default(
|
||||
None,
|
||||
None,
|
||||
Some(OsString::from("C:/Windows/system32/cmd.exe")),
|
||||
);
|
||||
assert_eq!(s.name, "cmd");
|
||||
// Unrecognized values fall through to a probe — result is still a
|
||||
// known shell.
|
||||
let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None);
|
||||
assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_validates_availability() {
|
||||
let sh = find("sh").unwrap();
|
||||
let bash = find("bash").unwrap();
|
||||
let mut session = ShellSession {
|
||||
current: sh,
|
||||
default: sh,
|
||||
available: vec![sh, bash],
|
||||
};
|
||||
assert_eq!(session.switch("bash").unwrap().name, "bash");
|
||||
assert_eq!(session.current.name, "bash");
|
||||
assert!(!session.is_default());
|
||||
let err = session.switch("zsh").unwrap_err();
|
||||
assert!(err.to_string().contains("not available"), "{err}");
|
||||
let err = session.switch("blorp").unwrap_err();
|
||||
assert!(err.to_string().contains("unknown shell"), "{err}");
|
||||
}
|
||||
}
|
||||
+88
-2
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const STATE_VERSION: u32 = 1;
|
||||
pub const STATE_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateFile {
|
||||
@@ -14,6 +14,13 @@ pub struct StateFile {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub installed: BTreeMap<String, InstalledEntry>,
|
||||
/// Total number of sessions started (incremented by the session
|
||||
/// registry; additive since state v2).
|
||||
#[serde(default)]
|
||||
pub sessions_count: u64,
|
||||
/// RFC 3339 timestamp of the last start/run of any agent.
|
||||
#[serde(default)]
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for StateFile {
|
||||
@@ -21,6 +28,8 @@ impl Default for StateFile {
|
||||
Self {
|
||||
version: STATE_VERSION,
|
||||
installed: BTreeMap::new(),
|
||||
sessions_count: 0,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,12 +85,18 @@ impl StateStore {
|
||||
}
|
||||
let text = std::fs::read_to_string(&self.path)
|
||||
.with_context(|| format!("cannot read state file {}", self.path.display()))?;
|
||||
let sf: StateFile = serde_json::from_str(&text).map_err(|e| {
|
||||
let mut sf: StateFile = serde_json::from_str(&text).map_err(|e| {
|
||||
anyhow!(
|
||||
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
|
||||
self.path.display()
|
||||
)
|
||||
})?;
|
||||
// Additive migration: older files (v1) load unchanged and are
|
||||
// rewritten as v2 on first contact.
|
||||
if sf.version < STATE_VERSION {
|
||||
sf.version = STATE_VERSION;
|
||||
self.save(&sf)?;
|
||||
}
|
||||
Ok(sf)
|
||||
}
|
||||
|
||||
@@ -130,6 +145,23 @@ impl StateStore {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the last use of an agent (start or run) — additive v2 data.
|
||||
pub fn touch(&self, name: &str) -> Result<()> {
|
||||
let mut sf = self.load()?;
|
||||
if sf.installed.contains_key(name) {
|
||||
sf.last_used = Some(crate::installers::now_rfc3339());
|
||||
self.save(&sf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Increment the total session counter (one per session start).
|
||||
pub fn bump_sessions(&self) -> Result<()> {
|
||||
let mut sf = self.load()?;
|
||||
sf.sessions_count = sf.sessions_count.saturating_add(1);
|
||||
self.save(&sf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -167,6 +199,60 @@ mod tests {
|
||||
assert!(st.all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_file_is_migrated_additively() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let st = store(dir.path());
|
||||
std::fs::write(
|
||||
st.path(),
|
||||
r#"{
|
||||
"version": 1,
|
||||
"installed": {
|
||||
"claude-code": {
|
||||
"name": "claude-code",
|
||||
"method": "npm",
|
||||
"install_dir": "/tmp/cc",
|
||||
"bins": [],
|
||||
"run": "claude",
|
||||
"installed_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let sf = st.load().unwrap();
|
||||
assert_eq!(sf.version, 2);
|
||||
assert_eq!(sf.sessions_count, 0);
|
||||
assert_eq!(sf.last_used, None);
|
||||
assert!(sf.installed.contains_key("claude-code"));
|
||||
// The migration is persisted on first contact.
|
||||
let raw = std::fs::read_to_string(st.path()).unwrap();
|
||||
assert!(raw.contains(r#""version": 2"#), "migrated file rewritten: {raw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_records_last_used() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let st = store(dir.path());
|
||||
st.touch("claude-code").unwrap(); // no entry: no-op
|
||||
assert_eq!(st.load().unwrap().last_used, None);
|
||||
let entry = InstalledEntry {
|
||||
name: "claude-code".to_string(),
|
||||
version: None,
|
||||
method: "npm".to_string(),
|
||||
install_dir: "/tmp/cc".to_string(),
|
||||
bins: vec![],
|
||||
run: "claude".to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
};
|
||||
st.set(&entry).unwrap();
|
||||
st.touch("claude-code").unwrap();
|
||||
assert!(st.load().unwrap().last_used.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
//! Generic Nushell-style tables: render a boxed table, filter rows with
|
||||
//! where-like predicates and select a column with get.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use colored::Colorize;
|
||||
|
||||
/// One cell: the text to display plus a typed value for filtering
|
||||
/// (number, byte size, or plain string).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cell {
|
||||
pub text: String,
|
||||
pub num: Option<f64>,
|
||||
pub bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn str(s: impl Into<String>) -> Self {
|
||||
Cell {
|
||||
text: s.into(),
|
||||
num: None,
|
||||
bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num(n: f64, text: String) -> Self {
|
||||
Cell {
|
||||
text,
|
||||
num: Some(n),
|
||||
bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes(b: u64, text: String) -> Self {
|
||||
Cell {
|
||||
text,
|
||||
num: None,
|
||||
bytes: Some(b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tabular result produced by ls, ps, where or get.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DataTable {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<Cell>>,
|
||||
}
|
||||
|
||||
impl DataTable {
|
||||
pub fn column_index(&self, name: &str) -> Option<usize> {
|
||||
self.columns
|
||||
.iter()
|
||||
.position(|c| c.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Render the boxed Nushell-style table, fitting max_width columns.
|
||||
/// When the natural layout is too wide, the first (name) column is
|
||||
/// truncated with an ellipsis and the trailing columns are dropped if
|
||||
/// that is still not enough — every row stays on a single line.
|
||||
pub fn render(&self, color: bool, max_width: usize) -> String {
|
||||
self.render_with(None, color, max_width)
|
||||
}
|
||||
|
||||
/// Render with the colors of a theme (borders dimmed, header row in the
|
||||
/// theme's header style instead of plain bold).
|
||||
pub fn render_themed(
|
||||
&self,
|
||||
theme: &crate::theme::Theme,
|
||||
color: bool,
|
||||
max_width: usize,
|
||||
) -> String {
|
||||
self.render_with(Some(theme), color, max_width)
|
||||
}
|
||||
|
||||
fn render_with(
|
||||
&self,
|
||||
theme: Option<&crate::theme::Theme>,
|
||||
color: bool,
|
||||
max_width: usize,
|
||||
) -> String {
|
||||
let max_width = max_width.max(20);
|
||||
let headers = self.columns.clone();
|
||||
if headers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// Column 0 is the row index; the data columns follow.
|
||||
let index_width = self.rows.len().to_string().len().max(1);
|
||||
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
for (i, row) in self.rows.iter().enumerate() {
|
||||
let mut line = vec![i.to_string()];
|
||||
for cell in row {
|
||||
line.push(cell.text.clone());
|
||||
}
|
||||
for (c, cell) in line.iter().enumerate().skip(1) {
|
||||
natural[c - 1] = natural[c - 1].max(visible_len(cell));
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
let line_width = |ncols: usize, widths: &[usize]| -> usize {
|
||||
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
|
||||
};
|
||||
|
||||
// Choose the final column set and widths.
|
||||
let mut data_cols = headers.len();
|
||||
let mut widths: Vec<usize> = natural.clone();
|
||||
loop {
|
||||
if data_cols == 0 {
|
||||
break;
|
||||
}
|
||||
widths.truncate(data_cols);
|
||||
if line_width(data_cols, &widths) <= max_width {
|
||||
// Expand the first column into the remaining slack.
|
||||
widths[0] += max_width - line_width(data_cols, &widths);
|
||||
break;
|
||||
}
|
||||
// Cap the non-first columns, then give the first what remains.
|
||||
let mut capped = widths.clone();
|
||||
for w in capped.iter_mut().skip(1) {
|
||||
*w = (*w).min(16);
|
||||
}
|
||||
if line_width(data_cols, &capped) <= max_width {
|
||||
capped[0] += max_width - line_width(data_cols, &capped);
|
||||
widths = capped;
|
||||
break;
|
||||
}
|
||||
if data_cols == 1 {
|
||||
// Last resort: shrink the single column to whatever remains
|
||||
// after the borders and the index column.
|
||||
let fixed = 1 + 3 * (data_cols + 1) + index_width;
|
||||
capped[0] = max_width.saturating_sub(fixed).max(1);
|
||||
widths = capped;
|
||||
break;
|
||||
}
|
||||
data_cols -= 1;
|
||||
}
|
||||
|
||||
let all_headers: Vec<String> = std::iter::once("#".to_string())
|
||||
.chain(headers.iter().take(data_cols).cloned())
|
||||
.collect();
|
||||
let mut all_widths = vec![index_width];
|
||||
all_widths.extend(widths.iter().cloned());
|
||||
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
for (i, w) in all_widths.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(mid);
|
||||
}
|
||||
line.push_str(&"─".repeat(w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let bar = |s: &str| -> String {
|
||||
if !color {
|
||||
return s.to_string();
|
||||
}
|
||||
match theme {
|
||||
Some(t) => t.frame(s),
|
||||
None => s.to_string(),
|
||||
}
|
||||
};
|
||||
let row_line = |row: &[String], header: bool| -> String {
|
||||
let mut line = bar("│");
|
||||
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
|
||||
line.push(' ');
|
||||
let cell = fit_cell(c, all_widths[i]);
|
||||
let rendered = if header {
|
||||
match theme {
|
||||
Some(t) if color => t.hdr(&cell),
|
||||
_ => cell.bold().to_string(),
|
||||
}
|
||||
} else {
|
||||
cell
|
||||
};
|
||||
line.push_str(&rendered);
|
||||
line.push(' ');
|
||||
line.push_str(&bar("│"));
|
||||
}
|
||||
line
|
||||
};
|
||||
let edge = |s: &str| -> String {
|
||||
if !color {
|
||||
return s.to_string();
|
||||
}
|
||||
match theme {
|
||||
Some(t) => t.frame(s),
|
||||
None => s.dimmed().to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&edge(&border('╭', '┬', '╮')));
|
||||
out.push('\n');
|
||||
out.push_str(&row_line(&all_headers, true));
|
||||
out.push('\n');
|
||||
out.push_str(&edge(&border('├', '┼', '┤')));
|
||||
out.push('\n');
|
||||
for line in &cells {
|
||||
out.push_str(&row_line(line, false));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&edge(&border('╰', '┴', '╯')));
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
|
||||
/// become numbers, everything else is a string.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WhereValue {
|
||||
Num(f64),
|
||||
Bytes(u64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
fn parse_value(raw: &str) -> WhereValue {
|
||||
let lower = raw.trim().to_lowercase();
|
||||
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
|
||||
if let Some(n) = lower.strip_suffix(suffix) {
|
||||
if let Ok(v) = n.trim().parse::<f64>() {
|
||||
return WhereValue::Bytes((v * mult as f64) as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(v) = raw.trim().parse::<f64>() {
|
||||
return WhereValue::Num(v);
|
||||
}
|
||||
WhereValue::Str(raw.trim().to_string())
|
||||
}
|
||||
|
||||
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
|
||||
match op {
|
||||
">" => a > b,
|
||||
"<" => a < b,
|
||||
">=" => a >= b,
|
||||
"<=" => a <= b,
|
||||
"==" => (a - b).abs() < f64::EPSILON,
|
||||
"!=" => (a - b).abs() >= f64::EPSILON,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter the rows of a table: where <field> <op> <value>.
|
||||
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
|
||||
let Some(idx) = table.column_index(field) else {
|
||||
bail!(
|
||||
"unknown column '{field}' — available: {}",
|
||||
table.columns.join(", ")
|
||||
);
|
||||
};
|
||||
let val = parse_value(value);
|
||||
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
|
||||
if !supported {
|
||||
bail!("unknown operator '{op}' — use > < >= <= == != =~");
|
||||
}
|
||||
let mut out = table.clone();
|
||||
out.rows.retain(|row| match row.get(idx) {
|
||||
None => false,
|
||||
Some(cell) => match &val {
|
||||
WhereValue::Num(n) => match cell.num {
|
||||
Some(v) => num_cmp(v, *n, op),
|
||||
None => false,
|
||||
},
|
||||
WhereValue::Bytes(b) => match cell.bytes {
|
||||
Some(v) => num_cmp(v as f64, *b as f64, op),
|
||||
None => false,
|
||||
},
|
||||
WhereValue::Str(s) => {
|
||||
let text = strip_ansi(&cell.text);
|
||||
match op {
|
||||
"==" => text.eq_ignore_ascii_case(s),
|
||||
"!=" => !text.eq_ignore_ascii_case(s),
|
||||
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Select one column: get <column>.
|
||||
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
|
||||
let Some(idx) = table.column_index(field) else {
|
||||
bail!(
|
||||
"unknown column '{field}' — available: {}",
|
||||
table.columns.join(", ")
|
||||
);
|
||||
};
|
||||
Ok(DataTable {
|
||||
columns: vec![field.to_string()],
|
||||
rows: table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| vec![row[idx].clone()])
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad a cell to its column width, or truncate it (with an ellipsis,
|
||||
/// preserving any leading color codes) when it is too wide.
|
||||
fn fit_cell(s: &str, w: usize) -> String {
|
||||
let n = visible_len(s);
|
||||
if n <= w {
|
||||
return format!("{s}{}", " ".repeat(w - n));
|
||||
}
|
||||
let plain = strip_ansi(s);
|
||||
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
|
||||
cut.push('…');
|
||||
if s.contains('') {
|
||||
let mut prefix = String::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
while chars.peek() == Some(&'') {
|
||||
for ch in chars.by_ref() {
|
||||
prefix.push(ch);
|
||||
if ch == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("{prefix}{cut}[0m")
|
||||
} else {
|
||||
cut
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
for n in chars.by_ref() {
|
||||
if n == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
strip_ansi(s).chars().count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> DataTable {
|
||||
DataTable {
|
||||
columns: vec!["name".into(), "type".into(), "size".into()],
|
||||
rows: vec![
|
||||
vec![
|
||||
Cell::str("src"),
|
||||
Cell::str("dir"),
|
||||
Cell::bytes(2048, "2.0 KB".into()),
|
||||
],
|
||||
vec![
|
||||
Cell::str("am.exe"),
|
||||
Cell::str("file"),
|
||||
Cell::bytes(5_000_000, "4.8 MB".into()),
|
||||
],
|
||||
vec![
|
||||
Cell::str("readme.md"),
|
||||
Cell::str("file"),
|
||||
Cell::bytes(512, "512 B".into()),
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_numeric_and_size() {
|
||||
let t = sample();
|
||||
let big = filter_where(&t, "size", ">", "1mb").unwrap();
|
||||
assert_eq!(big.rows.len(), 1);
|
||||
assert_eq!(big.rows[0][0].text, "am.exe");
|
||||
let eq = filter_where(&t, "type", "==", "dir").unwrap();
|
||||
assert_eq!(eq.rows.len(), 1);
|
||||
let contains = filter_where(&t, "name", "=~", "read").unwrap();
|
||||
assert_eq!(contains.rows.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_selects_column() {
|
||||
let t = sample();
|
||||
let names = get_column(&t, "name").unwrap();
|
||||
assert_eq!(names.columns, vec!["name".to_string()]);
|
||||
assert_eq!(names.rows.len(), 3);
|
||||
assert_eq!(names.rows[1][0].text, "am.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_column_errors() {
|
||||
let t = sample();
|
||||
assert!(filter_where(&t, "nope", "==", "x").is_err());
|
||||
assert!(get_column(&t, "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_where_values() {
|
||||
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
|
||||
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
|
||||
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
|
||||
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_is_aligned() {
|
||||
let t = sample();
|
||||
let text = t.render(false, 200);
|
||||
assert!(text.contains("╭"));
|
||||
assert!(text.contains("name"));
|
||||
assert!(text.contains("am.exe"));
|
||||
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fits_narrow_width() {
|
||||
// A table whose first column is too long to fit naturally.
|
||||
let t = DataTable {
|
||||
columns: vec!["name".into(), "type".into(), "size".into()],
|
||||
rows: vec![vec![
|
||||
Cell::str("a-very-long-file-name-that-overflows.txt"),
|
||||
Cell::str("file"),
|
||||
Cell::bytes(512, "512 B".into()),
|
||||
]],
|
||||
};
|
||||
// Trailing columns are dropped when too narrow.
|
||||
let text = t.render(false, 45);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 45, "too wide: {line}");
|
||||
}
|
||||
assert!(!text.contains("size"));
|
||||
// The name column is truncated with an ellipsis at the very end.
|
||||
let text = t.render(false, 30);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 30, "too wide: {line}");
|
||||
}
|
||||
assert!(text.contains("…"), "name should be truncated:
|
||||
{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_cell_truncates_and_keeps_color() {
|
||||
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
|
||||
let out = fit_cell(&colored, 8);
|
||||
assert_eq!(strip_ansi(&out).chars().count(), 8);
|
||||
assert!(out.starts_with("\u{1b}["));
|
||||
}
|
||||
}
|
||||
+403
@@ -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(Some("/tmp/demo".to_string()))
|
||||
.with_exit_code(0),
|
||||
)
|
||||
.unwrap();
|
||||
let all: Vec<Event> = events::read_events(dir.path(), 10);
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].agent.as_deref(), Some("jcode"));
|
||||
assert_eq!(all[0].cwd.as_deref(), Some("/tmp/demo"));
|
||||
assert_eq!(all[0].exit_code, Some(0));
|
||||
}
|
||||
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}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Structured history: record, read, filter, migration, command.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::commands::history_cmd;
|
||||
use agent_manager::history::{self, HistoryEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn cli_parses_history() {
|
||||
use agent_manager::cli::{Cli, Command};
|
||||
use clap::Parser;
|
||||
let cli = Cli::parse_from(["am", "history", "--failed", "--search", "install"]);
|
||||
match &cli.command {
|
||||
Some(Command::History {
|
||||
failed, search, ..
|
||||
}) => {
|
||||
assert!(*failed);
|
||||
assert_eq!(search.as_deref(), Some("install"));
|
||||
}
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_read_most_recent_first() {
|
||||
let app = common::test_app("hist", &["history"]);
|
||||
history::record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("ls")
|
||||
.with_kind("shell".to_string())
|
||||
.with_duration_ms(12),
|
||||
)
|
||||
.unwrap();
|
||||
let all = history::read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "ls");
|
||||
assert_eq!(all[0].kind, "shell");
|
||||
assert_eq!(all[0].duration_ms, Some(12));
|
||||
assert_eq!(all[1].cmd, "install jcode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_imports_flat_history_once() {
|
||||
let app = common::test_app("mig", &["history"]);
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
start group:dev
|
||||
|
||||
").unwrap();
|
||||
assert_eq!(history::migrate(&app).unwrap(), 2);
|
||||
assert_eq!(history::migrate(&app).unwrap(), 0, "no-op on second run");
|
||||
let all = history::read_all(&app, 0);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].cmd, "start group:dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_kind_search_and_failed() {
|
||||
let app = common::test_app("filt", &["history"]);
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("install jcode")
|
||||
.with_kind("am".to_string())
|
||||
.with_exit_code(Some(1)),
|
||||
)
|
||||
.unwrap();
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("ls -la")
|
||||
.with_kind("shell".to_string())
|
||||
.with_cwd(Some("/tmp".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(history_cmd::filter(&app, Some("shell"), None, None, false, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, None, Some("install"), false, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, None, None, true, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, Some("/tmp"), None, false, None, 10).len(), 1);
|
||||
assert_eq!(history_cmd::filter(&app, None, None, Some("zzz"), false, None, 10).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_runs_and_migrates() {
|
||||
let app = common::test_app("cmd", &["history"]);
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
").unwrap();
|
||||
let code = history_cmd::run(&app, None, None, None, false, None, 100, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// Migration created the structured file.
|
||||
assert!(history::dir(&app).join("migrated.jsonl").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerun_executes_shell_entry_with_yes() {
|
||||
let app = common::test_app("rerun", &["history", "--rerun", "1", "--yes"]);
|
||||
history::record(
|
||||
&app,
|
||||
"s1",
|
||||
&HistoryEntry::now("echo rerun-ok").with_kind("shell".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let code = history_cmd::run(&app, None, None, None, false, None, 100, Some(1)).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// Numéro hors plage : erreur claire.
|
||||
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(9)).is_err());
|
||||
// Zéro : erreur claire.
|
||||
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_session_file_parses() {
|
||||
// Guard for session_of_file used by the display layer.
|
||||
assert_eq!(
|
||||
history::session_of_file(&PathBuf::from("20260815_143926.jsonl")),
|
||||
Some("20260815_143926".to_string())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! init + context detection.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::commands::init_cmd;
|
||||
use agent_manager::context;
|
||||
|
||||
#[test]
|
||||
fn context_detects_stack_from_markers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||
let stack = context::detect_stack(dir.path());
|
||||
assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]);
|
||||
assert_eq!(context::recommended_agents(&stack), vec!["smelt", "claude-code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_writes_valid_config_and_refuses_overwrite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||
let path = init_cmd::generate(dir.path(), false).unwrap();
|
||||
assert!(path.ends_with("agent-manager.yaml"));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains(r#"version: "1.0""#), "config valide: {text}");
|
||||
assert!(text.contains("smelt"), "agent recommande pour rust: {text}");
|
||||
// Un second appel sans --force échoue.
|
||||
assert!(init_cmd::generate(dir.path(), false).is_err());
|
||||
// Avec --force, il écrase.
|
||||
assert!(init_cmd::generate(dir.path(), true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_uses_default_for_empty_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = init_cmd::generate(dir.path(), false).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("claude-code"), "default agent: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_runs_dry() {
|
||||
let app = common::test_app("initdry", &["init", "--dry-run"]);
|
||||
let code = init_cmd::run(&app, false).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Instrumentation: every real action appends one event to the journal;
|
||||
//! dry runs write nothing. Uses the same isolated-App pattern as the other
|
||||
//! integration tests (no user config or state leaks in).
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::app::App;
|
||||
use agent_manager::cli::{Cli, StartArgs};
|
||||
use agent_manager::commands::run_cmd;
|
||||
use agent_manager::events::{self, Event, EventKind};
|
||||
use agent_manager::state::InstalledEntry;
|
||||
use clap::Parser;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Build an isolated App whose catalog contains the given agent definition.
|
||||
fn app_with_agent(tag: &str, args: &[&str], agent_yaml: &str) -> App {
|
||||
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("am-instr-{tag}-{}-{id}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = common::write_config(&dir, agent_yaml);
|
||||
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
|
||||
full.extend(args.iter().map(|s| s.to_string()));
|
||||
let cli = Cli::parse_from(full);
|
||||
let mut app = App::from_cli(cli).expect("app should build");
|
||||
common::isolate(&mut app, &dir);
|
||||
app
|
||||
}
|
||||
|
||||
fn events(app: &App) -> Vec<Event> {
|
||||
events::read_events(&app.events_dir(), 0)
|
||||
}
|
||||
|
||||
const ECHO_AGENT: &str = r#"
|
||||
agents:
|
||||
- name: echo-agent
|
||||
install: { type: binary, url: "https://example.com/echo.zip" }
|
||||
run: sh
|
||||
args: ["-c", "echo hi"]
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn run_emits_one_run_event_with_exit_code() {
|
||||
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
|
||||
let code = run_cmd::run(&app, "echo-agent", &[OsString::from("hello")]).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let evs = events(&app);
|
||||
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
|
||||
assert_eq!(evs[0].kind, EventKind::Run);
|
||||
assert_eq!(evs[0].agent.as_deref(), Some("echo-agent"));
|
||||
assert_eq!(evs[0].exit_code, Some(0));
|
||||
assert!(evs[0].duration_s.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_writes_no_event() {
|
||||
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
|
||||
let code = run_cmd::run(&app, "echo-agent", &[]).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(events(&app).is_empty(), "dry-run must not append events");
|
||||
assert!(events::journal_files(&app.events_dir()).is_empty());
|
||||
}
|
||||
|
||||
fn sleeper_agent() -> String {
|
||||
let (run, args) = if cfg!(windows) {
|
||||
("ping", r#"["-n", "60", "127.0.0.1"]"#)
|
||||
} else {
|
||||
("sleep", r#"["60"]"#)
|
||||
};
|
||||
format!(
|
||||
r#"
|
||||
agents:
|
||||
- name: sleeper
|
||||
install: {{ type: binary, url: "https://example.com/sleeper.zip" }}
|
||||
run: {run}
|
||||
args: {args}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_and_stop_emit_a_matching_pair() {
|
||||
let app = app_with_agent("startstop", &["start", "sleeper", "--background"], &sleeper_agent());
|
||||
// The agent must be "managed" for the PID to be recorded and stoppable.
|
||||
let entry = InstalledEntry {
|
||||
name: "sleeper".to_string(),
|
||||
version: None,
|
||||
method: "binary".to_string(),
|
||||
install_dir: app.paths.install_dir.display().to_string(),
|
||||
bins: vec![],
|
||||
run: "sleeper".to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
};
|
||||
app.state.set(&entry).unwrap();
|
||||
|
||||
let opts = StartArgs {
|
||||
agent: Some("sleeper".to_string()),
|
||||
background: true,
|
||||
..Default::default()
|
||||
};
|
||||
let code = run_cmd::start(&app, &opts).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
|
||||
let stop_code = run_cmd::stop(&app, "sleeper", false, None).unwrap();
|
||||
assert_eq!(stop_code, 0);
|
||||
|
||||
let evs = events(&app);
|
||||
assert_eq!(evs.len(), 2, "expected Start + Stop, got {:?}", evs);
|
||||
assert_eq!(evs[0].kind, EventKind::Start);
|
||||
assert_eq!(evs[0].agent.as_deref(), Some("sleeper"));
|
||||
assert!(evs[0].pid.is_some());
|
||||
assert!(evs[0].session.is_some(), "start events carry a session id");
|
||||
assert_eq!(evs[1].kind, EventKind::Stop);
|
||||
assert_eq!(evs[1].pid, evs[0].pid);
|
||||
assert_eq!(evs[1].exit_code, Some(0));
|
||||
assert!(evs[1].duration_s.is_some(), "stop events carry the duration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_path_is_reachable() {
|
||||
// Guard: the shared fixture used by events_test stays parsable here too.
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/events-sample.jsonl");
|
||||
assert_eq!(events::parse_file(&p).len(), 2);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ fn spawn_detect_stop_cycle() {
|
||||
std::path::Path::new(prog),
|
||||
&args,
|
||||
&env,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pid > 0);
|
||||
@@ -57,6 +58,7 @@ fn spawning_a_failing_program_reports_an_error() {
|
||||
std::path::Path::new(prog),
|
||||
&args,
|
||||
&env,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! projects: aggregation from the journal, profiles from the config.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::commands::projects_cmd;
|
||||
use agent_manager::projects;
|
||||
|
||||
#[test]
|
||||
fn aggregates_projects_from_fixture_events() {
|
||||
let app = common::test_app("proj", &["projects"]);
|
||||
let month = chrono::Utc::now().format("%Y%m").to_string();
|
||||
let path = app.events_dir().join(format!("events-{month}.jsonl"));
|
||||
std::fs::create_dir_all(app.events_dir()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
[
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#,
|
||||
]
|
||||
.join("
|
||||
"),
|
||||
)
|
||||
.unwrap();
|
||||
let out = projects::aggregate(&app);
|
||||
assert_eq!(out.len(), 2);
|
||||
let api = out.iter().find(|s| s.root == "/home/x/api").unwrap();
|
||||
assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]);
|
||||
assert_eq!(api.dominant.as_deref(), Some("claude-code"));
|
||||
assert_eq!(api.sessions, 3);
|
||||
assert_eq!(api.launches, 3);
|
||||
assert_eq!(api.total_duration_s, 35);
|
||||
assert!(projects::cache_file(&app).exists());
|
||||
// La commande tourne (table + detail).
|
||||
assert_eq!(projects_cmd::run(&app, None).unwrap(), 0);
|
||||
assert_eq!(projects_cmd::run(&app, Some("api")).unwrap(), 0);
|
||||
assert!(projects_cmd::run(&app, Some("inconnu")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_config_parses_and_resolves() {
|
||||
let dir = std::env::temp_dir().join(format!("am-prof-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let root = dir.join("api");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let cfg = common::write_config(
|
||||
&dir,
|
||||
&format!(
|
||||
r#"
|
||||
projects:
|
||||
api:
|
||||
root: '{}'
|
||||
default_agent: claude-code
|
||||
env:
|
||||
FOO: bar
|
||||
"#,
|
||||
root.display()
|
||||
),
|
||||
);
|
||||
use agent_manager::cli::Cli;
|
||||
use clap::Parser;
|
||||
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "projects"]);
|
||||
let mut app = agent_manager::app::App::from_cli(cli).unwrap();
|
||||
common::isolate(&mut app, &dir);
|
||||
assert_eq!(app.config.projects.len(), 1);
|
||||
let sub = root.join("src");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
let (name, profile) = projects::profile_for(&app, &sub).unwrap();
|
||||
assert_eq!(name, "api");
|
||||
assert_eq!(profile.default_agent.as_deref(), Some("claude-code"));
|
||||
assert_eq!(
|
||||
projects::default_agent_for_dir(&app, &sub).as_deref(),
|
||||
Some("claude-code")
|
||||
);
|
||||
// Hors du projet : pas de profil.
|
||||
assert!(projects::default_agent_for_dir(&app, &std::env::temp_dir()).is_none());
|
||||
|
||||
// am start contextuel (issue #30) : résolution pure sans changer de cwd.
|
||||
use agent_manager::commands::run_cmd;
|
||||
assert_eq!(
|
||||
run_cmd::resolve_start_target(&app, &sub, None).unwrap(),
|
||||
"claude-code"
|
||||
);
|
||||
assert_eq!(
|
||||
run_cmd::resolve_start_target(&app, &sub, Some("aider")).unwrap(),
|
||||
"aider"
|
||||
);
|
||||
assert!(run_cmd::resolve_start_target(&app, &std::env::temp_dir(), None).is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Quick wins: logs, list filters, config set, alias, open, history dedup.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::commands::{alias_cmd, config_cmd, logs_cmd, open_cmd};
|
||||
use agent_manager::state::InstalledEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn installed(app: &agent_manager::app::App, name: &str, dir: &PathBuf) {
|
||||
let entry = InstalledEntry {
|
||||
name: name.to_string(),
|
||||
version: Some("1.0.0".to_string()),
|
||||
method: "binary".to_string(),
|
||||
install_dir: dir.display().to_string(),
|
||||
bins: vec![],
|
||||
run: name.to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
};
|
||||
app.state.set(&entry).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sorts_and_filters_running() {
|
||||
use agent_manager::commands::list_cmd;
|
||||
let app = common::test_app("listqw", &["list", "--all"]);
|
||||
let dir = app.paths.install_dir.join("x");
|
||||
installed(&app, "jcode", &dir);
|
||||
let code = list_cmd::run(&app, true, None, true, None).unwrap();
|
||||
assert_eq!(code, 0, "--running sur un parc arrete donne une liste vide");
|
||||
let code = list_cmd::run(&app, true, None, false, Some("version")).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(list_cmd::run(&app, true, None, false, Some("bogus")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_tail_reads_the_agent_log() {
|
||||
let app = common::test_app("logs", &["logs", "jcode"]);
|
||||
let dir = app.paths.log_dir.clone();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("jcode.log"), "ligne 1
|
||||
ligne 2
|
||||
").unwrap();
|
||||
let code = logs_cmd::run(&app, "jcode", 20, false).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(logs_cmd::log_path(&app, "jcode").unwrap(), dir.join("jcode.log"));
|
||||
assert!(logs_cmd::run(&app, "inconnu", 20, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_set_writes_and_validates() {
|
||||
let app = common::test_app("cfgset", &["config", "set", "settings.default_shell", "pwsh"]);
|
||||
let code = config_cmd::set_value(&app, "settings.default_shell", "pwsh").unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
|
||||
let text = std::fs::read_to_string(&cfg_path).unwrap();
|
||||
assert!(text.contains("pwsh"), "cle ecrite: {text}");
|
||||
// Une cle inconnue du schema est rejetee a la validation.
|
||||
assert!(config_cmd::set_value(&app, "settings.cle_inconnue", "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_add_list_remove() {
|
||||
let app = common::test_app("alias", &["alias", "add", "cc", "claude-code"]);
|
||||
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Add {
|
||||
name: "cc".to_string(),
|
||||
target: "claude-code".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
|
||||
let text = std::fs::read_to_string(&cfg_path).unwrap();
|
||||
assert!(text.contains("cc: claude-code"), "alias ecrit: {text}");
|
||||
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::List).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Remove {
|
||||
name: "cc".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// Cible inconnue refuse.
|
||||
assert!(alias_cmd::run(
|
||||
&app,
|
||||
&agent_manager::cli::AliasCmd::Add {
|
||||
name: "zz".to_string(),
|
||||
target: "agent-inconnu".to_string(),
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_resolves_install_dir() {
|
||||
let app = common::test_app("open", &["open", "jcode"]);
|
||||
let dir = app.paths.install_dir.join("jcode");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
installed(&app, "jcode", &dir);
|
||||
let target = open_cmd::resolve_target(&app, "jcode").unwrap();
|
||||
assert_eq!(target, dir);
|
||||
assert!(open_cmd::resolve_target(&app, "inconnu").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_migration_dedups_consecutive() {
|
||||
use agent_manager::history;
|
||||
let app = common::test_app("dedup", &["history"]);
|
||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||
std::fs::write(&txt, "install jcode
|
||||
install jcode
|
||||
list
|
||||
").unwrap();
|
||||
assert_eq!(history::migrate(&app).unwrap(), 2, "doublons consecutifs sautes");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! sessions command and session registry: lifecycle, counter, reconciliation
|
||||
//! and CLI parsing.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::cli::{Cli, Command};
|
||||
use agent_manager::commands::sessions_cmd;
|
||||
use agent_manager::sessions;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn cli_parses_sessions() {
|
||||
let cli = Cli::parse_from(["am", "sessions", "--status", "failed", "--show", "abc"]);
|
||||
match &cli.command {
|
||||
Some(Command::Sessions {
|
||||
agent,
|
||||
status,
|
||||
show,
|
||||
..
|
||||
}) => {
|
||||
assert!(agent.is_none());
|
||||
assert_eq!(status.as_deref(), Some("failed"));
|
||||
assert_eq!(show.as_deref(), Some("abc"));
|
||||
}
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_session_roundtrip_and_counter() {
|
||||
let app = common::test_app("sess", &["sessions"]);
|
||||
let id = sessions::start_agent(
|
||||
&app,
|
||||
"claude-code",
|
||||
4242,
|
||||
Some("/tmp".to_string()),
|
||||
None,
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sf = sessions::load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "running");
|
||||
assert_eq!(r.agent.as_deref(), Some("claude-code"));
|
||||
assert_eq!(app.state.load().unwrap().sessions_count, 1);
|
||||
|
||||
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let sf = sessions::load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "stopped");
|
||||
assert_eq!(r.exit_code, Some(0));
|
||||
assert!(r.ended_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_lists_sessions() {
|
||||
let app = common::test_app("sesscmd", &["sessions"]);
|
||||
sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// --json path returns the row set without error.
|
||||
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_unknown_or_repl_session_errors() {
|
||||
let app = common::test_app("resume", &["sessions"]);
|
||||
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue")).is_err());
|
||||
sessions::start_repl(&app, "r1", None).unwrap();
|
||||
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_dry_run_announces() {
|
||||
let app = common::test_app("resumedry", &["sessions", "--dry-run"]);
|
||||
let id = sessions::start_agent(&app, "claude-code", 4242, None, None, vec![], vec![], None).unwrap();
|
||||
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id)).unwrap();
|
||||
assert_eq!(code, 0, "dry-run resume");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_one_accepts_known_id() {
|
||||
let app = common::test_app("sessshow", &["sessions"]);
|
||||
let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! stats + log: aggregation and filtering over the event journal.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::app::App;
|
||||
use agent_manager::commands::log_cmd;
|
||||
use agent_manager::commands::stats_cmd;
|
||||
use agent_manager::events;
|
||||
|
||||
fn app_with_events(tag: &str, lines: &[&str]) -> App {
|
||||
let app = common::test_app(tag, &["stats"]);
|
||||
let month = chrono::Utc::now().format("%Y%m").to_string();
|
||||
let path = app.events_dir().join(format!("events-{month}.jsonl"));
|
||||
let mut text = String::new();
|
||||
for l in lines {
|
||||
text.push_str(l);
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::create_dir_all(app.events_dir()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_aggregate_counts_everything() {
|
||||
let app = app_with_events(
|
||||
"agg",
|
||||
&[
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
|
||||
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
|
||||
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
|
||||
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
|
||||
r#"{"ts":"2026-08-03T12:00:00Z","kind":"update","agent":"a"}"#,
|
||||
],
|
||||
);
|
||||
let rows = stats_cmd::aggregate(&app, None, None).unwrap();
|
||||
let a = rows.iter().find(|r| r.agent == "a").unwrap();
|
||||
assert_eq!(a.launches, 2);
|
||||
assert_eq!(a.total_duration_s, 40);
|
||||
assert_eq!(a.avg_duration_s, 20);
|
||||
assert_eq!(a.failures, 0);
|
||||
assert_eq!(a.installs, 1);
|
||||
assert_eq!(a.updates, 1);
|
||||
let b = rows.iter().find(|r| r.agent == "b").unwrap();
|
||||
assert_eq!(b.failures, 1);
|
||||
assert_eq!(b.launches, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_period_cuts_old_events() {
|
||||
let app = app_with_events(
|
||||
"period",
|
||||
&[r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#],
|
||||
);
|
||||
assert!(stats_cmd::aggregate(&app, None, Some("30d")).unwrap().is_empty());
|
||||
assert_eq!(stats_cmd::aggregate(&app, None, None).unwrap().len(), 1);
|
||||
assert!(stats_cmd::aggregate(&app, None, Some("7d")).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_filters_by_agent_kind_and_limit() {
|
||||
let app = app_with_events(
|
||||
"logf",
|
||||
&[
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-01T10:00:01Z","kind":"start","agent":"a","pid":42}"#,
|
||||
r#"{"ts":"2026-08-01T10:00:02Z","kind":"run","agent":"b","exit_code":1}"#,
|
||||
],
|
||||
);
|
||||
let all = log_cmd::filter_events(&app, None, None, None, 50).unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].agent.as_deref(), Some("b"), "most recent first");
|
||||
|
||||
let only_a = log_cmd::filter_events(&app, Some("a"), None, None, 50).unwrap();
|
||||
assert_eq!(only_a.len(), 2);
|
||||
|
||||
let starts = log_cmd::filter_events(&app, None, Some("start"), None, 50).unwrap();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(starts[0].pid, Some(42));
|
||||
|
||||
let limited = log_cmd::filter_events(&app, None, None, None, 1).unwrap();
|
||||
assert_eq!(limited.len(), 1);
|
||||
|
||||
let since = log_cmd::filter_events(&app, None, None, Some("2026-09-01"), 50).unwrap();
|
||||
assert!(since.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_command_runs() {
|
||||
let app = app_with_events(
|
||||
"cmd",
|
||||
&[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#],
|
||||
);
|
||||
let code = stats_cmd::run(&app, None, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_reads_back_through_events_api() {
|
||||
let app = app_with_events("api", &[]);
|
||||
let mut log = events::EventLog::new(app.events_dir());
|
||||
log.append(
|
||||
&events::Event::now(events::EventKind::Run)
|
||||
.with_agent("x")
|
||||
.with_exit_code(0),
|
||||
)
|
||||
.unwrap();
|
||||
let all = events::read_events(&app.events_dir(), 0);
|
||||
assert_eq!(all.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! top, report and log --follow (issues #37 #38).
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::commands::stats_cmd;
|
||||
use agent_manager::events;
|
||||
|
||||
fn app_with_events(tag: &str, lines: &[&str]) -> agent_manager::app::App {
|
||||
let app = common::test_app(tag, &["stats"]);
|
||||
let month = chrono::Utc::now().format("%Y%m").to_string();
|
||||
let path = app.events_dir().join(format!("events-{month}.jsonl"));
|
||||
std::fs::create_dir_all(app.events_dir()).unwrap();
|
||||
let mut text = String::new();
|
||||
for l in lines {
|
||||
text.push_str(l);
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_sorts_by_launches() {
|
||||
let app = app_with_events(
|
||||
"top",
|
||||
&[
|
||||
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
|
||||
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":0}"#,
|
||||
],
|
||||
);
|
||||
let rows = stats_cmd::top(&app, None).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].agent, "a");
|
||||
assert_eq!(rows[0].launches, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_md_contains_numbers_and_table() {
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let lines = [
|
||||
format!(r#"{{"ts":"{today}T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}}"#),
|
||||
format!(r#"{{"ts":"{today}T10:01:00Z","kind":"run","agent":"a","exit_code":1,"duration_s":5}}"#),
|
||||
format!(r#"{{"ts":"{today}T10:02:00Z","kind":"install","agent":"b"}}"#),
|
||||
];
|
||||
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||
let app = app_with_events("report", &refs);
|
||||
let md = stats_cmd::report_md(&app, 7);
|
||||
assert!(md.contains("# Rapport agent-manager"), "md: {md}");
|
||||
assert!(md.contains("Lancements : 2"));
|
||||
assert!(md.contains("Échecs : 1"));
|
||||
assert!(md.contains("Changements"));
|
||||
assert!(md.contains("| a | 2 |"), "table top: {md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_command_writes_file() {
|
||||
let app = app_with_events("reportcmd", &[]);
|
||||
let out = app.events_dir().join("report-test.md");
|
||||
let code = stats_cmd::run_report(&app, true, false, Some(&out)).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(out.exists());
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_is_utf8_safe() {
|
||||
use agent_manager::commands::log_cmd;
|
||||
let text = "héllo wörld
|
||||
second line
|
||||
";
|
||||
assert_eq!(log_cmd::text_after(text, 0), text);
|
||||
assert_eq!(log_cmd::text_after(text, 3), "llo wörld
|
||||
second line
|
||||
");
|
||||
let full = text.len();
|
||||
assert_eq!(log_cmd::text_after(text, full), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_flag_parses() {
|
||||
use agent_manager::cli::{Cli, Command};
|
||||
use clap::Parser;
|
||||
let cli = Cli::parse_from(["am", "log", "--follow"]);
|
||||
match &cli.command {
|
||||
Some(Command::Log { follow, .. }) => assert!(*follow),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_still_reads_after_events_api() {
|
||||
let app = app_with_events("api", &[]);
|
||||
let mut log = events::EventLog::new(app.events_dir());
|
||||
log.append(&events::Event::now(events::EventKind::Run).with_agent("x")).unwrap();
|
||||
assert_eq!(events::read_events(&app.events_dir(), 0).len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user