Compare commits

...
26 Commits
Author SHA1 Message Date
bruno ba901df953 v0.2.14 : fallback web GitHub pour les releases - quand l API est rate-limitee, le tag vient de la redirection /releases/latest et les assets de expanded_assets (aucune limite) - jcode et zeroclaw installables sans GITHUB_TOKEN 2026-08-16 21:53:25 -04:00
bruno e50e8d3e41 v0.2.14 : kimi-cli via uv (paquet PyPI kimi-cli, fini le npm inexistant) + uv venv autonome (python 3.13 gere par uv, plus besoin de python systeme pour les installs uv) 2026-08-16 21:41:21 -04:00
bruno 498af684c9 v0.2.14 : support GITHUB_TOKEN/GH_TOKEN pour l API GitHub (limite 60 -> 5000 requetes/h) et message clair sur le rate limit 403 2026-08-16 21:30:31 -04:00
bruno 2282f71829 v0.2.14 : correction install binaire - detection des archives par magic bytes (un zip renomme n est plus copie tel quel), preference des archives sur les setup/installers, validation de l executable (MZ/ELF/Mach-O) avec erreur claire avant le premier start 2026-08-16 21:23:08 -04:00
bruno f968d7d0b0 v0.2.14 : 9 nouveaux themes - nord, solarized, gruvbox, catppuccin, tokyo-night, monokai, matrix, rose-pine, amber (15 au total), liste des themes sur deux lignes dans la banniere 2026-08-16 21:01:15 -04:00
bruno b3437b0601 v0.2.14 : correction bordure haute du splash - les tirets a droite du titre gardent la couleur du theme (reset du titre ne casse plus le cadre) 2026-08-16 20:45:21 -04:00
bruno 843c3e1278 Merge pull request 'events : journal d'evenements JSONL (issue #2)' (#17) from feat/events-ledger into main 2026-08-16 13:50:24 -04:00
bruno 4db567eebe events : journal d'evenements JSONL (issue #2) - append-only, rotation mensuelle, lecture tolerante aux lignes corrompues, env_keys sans valeurs secretes, 8 tests 2026-08-16 13:49:11 -04:00
bruno 2bbe45e508 v0.2.14 : barres verticales des cadres aux couleurs du theme + commande theme dans le shell (theme liste les themes, theme <name> change a chaud, completion et menu synchronises) 2026-08-16 13:40:51 -04:00
bruno 4b0f02a7c4 v0.2.14 : cadres aux couleurs du theme - bordures des tableaux (list, search, status, doctor, ls, dir, ps, help), version, menu d autocompletion et banniere colorees par theme (border) 2026-08-16 13:17:07 -04:00
bruno 79143e197d v0.2.14 : splash screen theme - art ASCII et bordures aux couleurs du theme, section Theme (theme actif + liste des themes disponibles), ligne de statut theme 2026-08-16 13:10:07 -04:00
bruno 14f873bfdd v0.2.14 : aide Nushell, themes de couleurs et cadres blancs
- Aide facon Nushell : help <cmd>, <cmd> -h/--help, help, help commands,
  help --find <texte>, help <agent> (fiche info) - sections Usage/Flags/
  Command Type/Parameters/Input-output/Examples avec couleurs
- Commande version : tableau Nushell (commit, build, rustc, theme...) + --json ;
  build.rs pour commit/branche git ; -V reste la version courte
- Themes : --theme <nom> (default, ocean, sunset, forest, dracula, mono)
  + settings.theme dans la config ; activation VT Windows pour les couleurs
- Cadres blancs Nushell partages : list, search, status, doctor, ls/ps/
  where/get et menu d autocompletion Tab encadre (colonnes name/description)
2026-08-16 12:52:12 -04:00
bruno 53f3fa34e7 docs: roadmap section 11 liee aux issues Gitea (suivi phase 0 en place) 2026-08-16 12:51:52 -04:00
bruno 5841926b25 docs: roadmap v2 - lecture facilitee (sommaire, legende, quick wins, KPI), 12 axes enrichis (modeles locaux, lab, rapports, couts, favoris, profils), jalons versionnes 2026-08-16 12:31:29 -04:00
bruno 979875fa63 v0.2.13 : prompt avec emojis colores — 🗂️ repertoire, 🤖 catalogue, 🌐 externes, 📊 geres, ⚡ duree de la derniere commande, ⏲ session 2026-08-16 10:43:26 -04:00
bruno 93c32b60ba v0.2.12 : ligne de statut — temps dexecution de la derniere commande (⚡ ms/s) + ls parallele (tailles de dossiers reparties sur les coeurs, un seul appel de metadonnees par entree) — 130 entrees instantanees 2026-08-16 10:29:38 -04:00
bruno 0f4ba71219 v0.2.11 : ligne de statut et banniere — compteurs distincts agents externes (PATH) et agents geres (am install) pour lever l ambiguite du 0 installed 2026-08-16 10:08:54 -04:00
bruno e7cb136c38 v0.2.10 : affichage ls/dir adapte a la largeur du terminal — detection de la largeur de la console (AM_TABLE_WIDTH pour tests), troncature de la colonne nom avec …, suppression des colonnes de fin si necessaire, une ligne par entree 2026-08-16 09:38:54 -04:00
bruno 57234b7a38 v0.2.9 : ps/where/get facon Nushell avec mini-pipeline (ls | where size > 1mb | get name), dir = ls sur Windows, ls beaucoup plus rapide (taille des dossiers bornee a 500 entrees), autocomplet : prefixe commun + menu avec descriptions + fallback chemins 2026-08-16 09:11:09 -04:00
bruno 3e1b66809a v0.2.8 : navigation facon Nushell — commande ls en tableau encadre (type, taille, date), completion Tab des dossiers pour cd (niveau par niveau) et des chemins pour ls, descriptions dans le menu Tab 2026-08-15 23:42:32 -04:00
bruno d886de322c v0.2.7 : self-uninstall supprime maintenant le binaire — renommage immediat de l executable en cours dexecution (am.exe.deleteme) + script .bat temporaire avec reessais, balayage des dossiers vides et auto-suppression 2026-08-15 23:19:28 -04:00
bruno bc38cd59e5 v0.2.6 : colonne AGENT (slug) dans am list + desinstallation des agents externes (npm/pip/uv/cargo/bun ou suppression des fichiers, shims .cmd gerees) 2026-08-15 22:20:51 -04:00
bruno 61f4909bc6 v0.2.5 : info enrichi (statut, versions, dependances verifiees) + self-uninstall tolerant (fichiers verrouilles / lecture seule, balayage des dossiers vides) + agent deepseek-harness au catalogue 2026-08-15 21:10:14 -04:00
bruno 98aa75d185 doc : desinstallation Windows — commande PowerShell corrigee + variante cmd clarifiee 2026-08-15 20:09:43 -04:00
bruno ba07f42d09 v0.2.4 : prompt interactif style Hermes + passerelle shell systeme (shell <nom>, detection du shell par defaut) + desinstallation propre (self-uninstall) 2026-08-15 19:59:24 -04:00
bruno 2be5f8e8b2 v0.2.3 : execution shell des commandes avec pipe (rustup, bun, uv) + rafraichissement du PATH + python3 satisfait python 2026-08-15 16:42:47 -04:00
37 changed files with 7073 additions and 266 deletions
Generated
+2 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.2"
version = "0.2.14"
dependencies = [
"anyhow",
"chrono",
@@ -41,6 +41,7 @@ dependencies = [
"ureq",
"wait-timeout",
"which",
"windows-sys 0.52.0",
"zip",
]
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.2.2"
version = "0.2.14"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -35,6 +35,9 @@ wait-timeout = "0.2"
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
+67 -6
View File
@@ -90,7 +90,7 @@ 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 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
@@ -112,7 +112,8 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am config show / edit / add | gère votre configuration |
| 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 +167,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.)
---
+393
View File
@@ -0,0 +1,393 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.0** · Statut : propositions, non engagées
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
---
## 📖 Sommaire
1. [Résumé exécutif](#1--résumé-exécutif)
2. [Légende de lecture](#2--légende-de-lecture)
3. [Quick wins — valeur immédiate](#3--quick-wins--valeur-immédiate)
4. [État des lieux](#4--état-des-lieux)
5. [Vision & principes](#5--vision--principes)
6. [Socle technique : le modèle de données](#6--socle-technique--le-modèle-de-données)
7. [Les 12 axes](#7--les-12-axes)
8. [Jalons versionnés](#8--jalons-versionnés)
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
10. [Risques & garde-fous](#10--risques--garde-fous)
11. [Par où commencer](#11--par-où-commencer)
---
## 1. 📌 Résumé exécutif
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
Mais il est **aveugle** : il ne retient ni ce qui a été fait, ni quand, ni
par quel agent, ni dans quel projet, ni avec quel résultat. Les logs sont du
texte libre non interrogeable, l'état ne contient que les installations et
le PID courant, l'historique est un simple fichier plat.
Cette roadmap transforme **am** en **cockpit de votre parc d'agents IA** :
| Rôle | Promesse | Porté par |
|---|---|---|
| 📓 Journal de bord | tout est tracé, rien ne se perd | Axe 1 (journal d'événements) |
| 🔎 Rétroviseur | tout est cherchable en 2 secondes | Axes 2, 3, 4 |
| 📊 Tableau de bord | tout est mesuré | Axes 1, 5, 9 |
| 🤖 Copilote | il pilote à votre place | Axes 6, 7 |
**Chiffres clés :** 12 axes · ~55 fonctionnalités · 4 jalons (v0.3.0 → v1.0)
· ~15 semaines de travail cumulé · 0 dépendance runtime ajoutée.
---
## 2. 📚 Légende de lecture
| Symbole | Signification |
|---|---|
| **P0** | Fondations — à démarrer tout de suite (socle des autres axes) |
| **P1** | Court terme — après la phase 0 |
| **P2** | Moyen terme |
| **P3** | Vision — long terme |
| ⭐ | Reprend une demande explicite de l'utilisateur |
| **S** | Effort : moins d'1 jour |
| **M** | Effort : 2 à 4 jours |
| **L** | Effort : 1 à 2 semaines |
| **XL** | Effort : plus de 2 semaines |
| ⬜ / 🔨 / ✅ | Proposé / En cours / Livré (rien n'est commencé aujourd'hui) |
---
## 3. ⚡ Quick wins — valeur immédiate
*Tous en effort S, phase P0, sans dépendance sur le socle. Livrables en
quelques jours, à caler avant ou pendant la construction du journal.*
| Fonctionnalité | Commande | Apport |
|---|---|---|
| Voir les logs d'un agent | am logs claude-code (--follow) | tail des logs déjà écrits par process.rs |
| Filtres sur la liste | am list --running · --sort name,version,status | repérage instantané |
| Éditer la config sans éditeur | am config set key value | moins de friction YAML |
| Alias à la volée | am alias add cc claude-code | sans éditer le fichier |
| Historique propre | dédup + horodatage de history.txt | base saine pour l'axe 2 |
| Doctor scriptable | am doctor --json | CI et monitoring |
| Ouvrir l'installation | am open claude-code | explorer le répertoire de l'agent |
| Version scriptable | am version --json | introspection des scripts |
---
## 4. 🧭 État des lieux (points d'ancrage dans le code)
| Brique actuelle | Fichier | Limite aujourd'hui |
|---|---|---|
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
| Installation (9 méthodes), dépendances OS | src/installers/, src/deps.rs, src/toolchain.rs | solide — rien à redire |
| État local | src/state.rs (state.json v1) | installations + PID courant uniquement, **aucun historique** |
| Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
| Processus | src/process.rs | logs non structurés, pas de sessions, pas de métriques |
| Journal applicatif | src/output.rs | texte libre horodaté, non interrogeable |
| REPL + passerelle shell | src/repl.rs (history.txt, session_id()) | historique plat, pas de recherche ni de stats |
| Backup | src/commands/export_import.rs | manuel, pas de synchronisation |
---
## 5. 🎯 Vision & principes directeurs
**Vision :** le cockpit unique du parc d'agents — journal de bord, rétroviseur,
tableau de bord et copilote.
**Principes :**
1. **Zéro dépendance runtime** — tout est Rust pur et fichiers locaux
(JSONL/JSON), jamais de base de données externe.
2. **Local par défaut, opt-in pour le distant** — la télémétrie n'existe que
si l'utilisateur l'active ; tout le reste est 100 % local.
3. **Contrats stables** — les sorties --json sont la base des scripts et des
futurs UI : champs ajoutés, jamais retirés.
4. **Performance** — écriture en append (JSONL), lectures par fenêtres
temporelles, index dérivés régénérables, caches sur le modèle de probe.rs.
5. **Livraison continue** — chaque phase produit un binaire utilisable et
testé ; la barre des 45+ tests monte à chaque jalon.
---
## 6. 🧱 Socle technique : le modèle de données
*Tout dépend de ceci. À construire en premier, avant les commandes visibles.*
state_dir/
state.json # v2 : + sessions_count, last_used (migration auto depuis v1)
events.jsonl # NOUVEAU — journal d'événements (source de vérité)
events-202608.jsonl # rotation mensuelle
sessions.json # NOUVEAU — registre des sessions (index dérivé)
history/
<session>.jsonl # NOUVEAU — historique structuré du REPL
projects.json # NOUVEAU — agrégats par projet (recalculable)
probe-cache.json # existant — pattern de cache à suivre
| Fichier | Rôle | Alimenté par | Reconstruction |
|---|---|---|---|
| events.jsonl | faits bruts (start, stop, run, install…) | process.rs, run_cmd.rs, install_cmd.rs, repl.rs, doctor_cmd.rs | source de vérité, jamais reconstruit |
| sessions.json | index des sessions | dérivé du journal | doctor --fix (rejoue le journal) |
| projects.json | agrégats par projet | dérivé du journal | doctor --fix |
| history/*.jsonl | commandes REPL | repl.rs | non (données primaires) |
| state.json v2 | installations + résumé | state.rs | partiellement (compteurs depuis le journal) |
**Règles :** JSONL append-only pour les faits · JSON dérivés régénérables ·
migration additive (state v1 reste lisible partout) · doctor --fix répare
tout ce qui peut l'être.
---
## 7. 🗂️ Les 12 axes
### Axe 1 — 📊 Observabilité & statistiques d'utilisation ⭐
🎯 Savoir **quoi** a tourné, **quand**, **combien de temps**, avec **quel
taux de succès** — et **combien ça coûte**.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Journal d'événements events.jsonl (toutes les actions de am) | M | P0 |
| am stats + --period 7d/30d/90d + --json (tableaux et barres ASCII) | M | P0 |
| am top — classement des agents par utilisation | S | P1 |
| am log — filtres agent/type/date, --follow | S → M | P0 → P1 |
| am report — rapport hebdo/mensuel en markdown (top agents, échecs, changements) | M | P1 |
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) | M | P2 |
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes | L | P2 |
| Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
am stats # vue globale
am stats claude-code --period 30d
am top # qui tourne le plus
am report --last-week # digest markdown
### Axe 2 — 💾 Historique des commandes ⭐
🎯 Gérer, **rechercher** et **réutiliser** tout ce qui a été tapé.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Historique structuré : history/<session>.jsonl (cmd, kind am/shell, cwd, durée, exit, agent) + migration de history.txt | M | P0 |
| am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 |
| Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 |
| am history --rerun 42 (réexécution confirmée) | S | P1 |
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml | L | P2 |
am history --search "install" --failed
am history --rerun 42
### Axe 3 — 🧭 Projets & workspaces ⭐
🎯 Centraliser **quel agent travaille sur quel projet** — automatiquement.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Contexte auto au lancement : racine + branche git, stack détectée (Cargo.toml, package.json…), mis en cache | S | P0 |
| am projects / am projects api — agents, sessions, durées, dernière activité | M | P1 |
| Profils de projet dans config : default_agent, env, hooks | M | P1 |
| am start (sans argument) = agent par défaut du dossier courant ; REPL contextuel | S | P1 |
| am init — génère un agent-manager.yaml selon la stack détectée | S → M | P0 |
| Templates : am init --template web | M | P2 |
am projects
am projects api
am init # dans un projet Node → config pré-remplie
### Axe 4 — 🗂️ Sessions centralisées ⭐
🎯 Un registre unique de **toutes les sessions de tous les agents** —
consultable, reprenable, archivable.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| sessions.json + session_id() partagé (repl.rs) ; fin de session avec exit code et durée | M | P0 |
| Réconciliation au démarrage : sessions interrompues (crash, reboot) marquées et datées | S | P0 |
| am sessions — filtres agent/projet/statut ; show = résumé + extrait de log | M | P0 |
| am timeline — vue chronologique unifiée de toute l'activité | M | P1 |
| am sessions resume <id> — relance avec les mêmes args/env/cwd | M | P1 |
| am sessions export <id> + rétention configurable (settings.sessions_retention_days) | M | P2 |
am sessions --status failed
am sessions show 20260815_143926_a1b2c3
am timeline --project api
### Axe 5 — 🖥️ Tableau de bord
🎯 Voir l'état du parc d'un coup d'œil.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| am dashboard — TUI (ratatui/crossterm) : vue d'ensemble, activité en direct, stats, sessions, projets | L | P1 |
| am web — serveur local + API JSON + page HTML embarquée avec graphiques | XL | P2 |
### Axe 6 — 🤖 Automatisation & orchestration
🎯 Laisser am **superviser et piloter seul** le parc.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 |
| am watch claude-code --restart-on-crash --notify | M | P1 |
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart | L | P2 |
| Planification : am schedule --daily update --all + check santé (cron / Task Scheduler) | M | P2 |
| Orchestration de groupes : ordre, --parallel, attente de santé | L | P2 |
| Conteneurs : profil docker/podman par agent (isolation à la demande) | L | P2 |
| am doctor --watch — vérifications périodiques avec alertes | M | P2 |
### Axe 7 — 🧠 Intelligence & catalogue
🎯 Trouver le bon agent, suivre leurs sorties, et comparer.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 |
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 |
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
| Registre communautaire : publier son catalogue (Gitea) + am registry | L | P3 |
| am ask "installe claude et lance-le" — langage naturel → commande am (fournisseur LLM configurable, optionnel) | L | P3 |
### Axe 8 — 🔒 Sécurité & gouvernance
🎯 Protéger les secrets, tracer les changements, pouvoir revenir en arrière.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| am secret set OPENAI_KEY --agent claude-code (keyring OS) + injection --env automatique | M | P1 |
| am audit — qui a modifié quoi quand (checksums des configs, événements) | M | P2 |
| am update --rollback — backup automatique avant chaque mise à jour | M | P2 |
| Politiques : pin de version, settings.update_policy | S | P2 |
| Profils sandbox par agent (commandes/répertoires autorisés) | L | P3 |
### Axe 9 — 🌐 Multi-machine & collaboration
🎯 Retrouver son cockpit partout, et le partager.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 |
| Partage de catalogue d'équipe : include par URL | S | P2 |
| am migrate — assistant de transfert machine A → B | M | P2 |
| am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
### Axe 10 — 🧩 Confort & personnalisation
🎯 Adapter am à sa façon de travailler.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
| Notes : am note claude-code "utiliser pour …" | S | P1 |
| Tags personnels : am tag claude-code python | S | P1 |
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 |
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
| Thèmes couleurs du REPL | S | P2 |
### Axe 11 — 🎛️ Modèles locaux
🎯 Étendre le cockpit aux modèles : **ollama, llama.cpp, LM Studio**.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| am models — inventaire : nom, taille disque, quantisation, dernière utilisation | M | P2 |
| Lien agent ↔ modèle : am run mon-agent --model llama3.1 | M | P2 |
| doctor vérifie ollama / llama-server comme n'importe quel outil | S | P2 |
| am models prune — purge des modèles inutilisés | S | P2 |
### Axe 12 — 🛠️ Écosystème & expérience développeur
🎯 Rendre am disponible partout et contribuable.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Fixtures d'événements rejouables + tests de bout en bout du journal | S | P0 |
| Packaging officiel : winget, scoop, deb/rpm, Homebrew + CI de release | M | P1 |
| Complétions dynamiques (agents installés, groupes personnalisés) | S | P1 |
| Man pages + documentation générée | S | P1 |
| i18n : messages EN/FR | L | P2 |
| Plugin scripts (hooks avancés, intégration CI) | M | P2 |
---
## 8. 🚀 Jalons versionnés
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|---|---|---|---|---|
| **J0 — Fondations** | v0.3.0 | events.jsonl, sessions, am stats, am log, am history, am init, quick wins | ≈ 2 sem | 100 % des actions tracées · 100+ tests |
| **J1 — Cockpit** | v0.4.0 | recherche fuzzy, Ctrl-R, projects, timeline, dashboard TUI, hooks, secret, favoris, packaging | ≈ 3 sem | retrouver n'importe quelle action passée en < 2 s |
| **J2 — Automatisation** | v0.5.0 | watch, services, schedule, news/compare, catalogue distant, lab, models, sync | ≈ 4 sem | parc auto-supervisé (crash = redémarrage + alerte) |
| **J3 — Plateforme** | v1.0 | am web, am serve, am ask, registry, télémétrie opt-in, sandbox | ≈ 6 sem | dashboard web complet + API distante |
---
## 9. 📈 Indicateurs de succès (KPI)
| Indicateur | Cible |
|---|---|
| Part des actions tracées dans events.jsonl | 100 % dès la v0.3.0 |
| Temps pour retrouver une commande ou session passée | < 2 s |
| am list à chaud, journal actif | < 100 ms |
| Dépendances runtime ajoutées | 0 |
| Couverture de tests | ≥ 100 tests en v0.3, +25 par jalon |
| Contrats --json cassés entre versions mineures | 0 |
---
## 10. ⚠️ Risques & garde-fous
1. **ADN « zéro runtime »** : toute dépendance nouvelle est compilée dans le
binaire, jamais requise à l'exécution.
2. **Performance** : le journal grossit — fenêtres temporelles, index dérivés,
rotation mensuelle, caches façon probe.rs.
3. **Vie privée** : local par défaut ; télémétrie opt-in, agrégée, anonyme.
4. **Contrats --json** : ajout de champs sans retrait (scripts utilisateurs).
5. **Compatibilité** : migrations additives ; les exports 0.2.x restent
importables en 0.3+.
6. **Complexité UX** : les fonctions avancées restent derrière
settings.experimental jusqu'à maturité.
7. **Périmètre** : rester un orchestrateur — ne pas réimplémenter les
fonctionnalités propres aux agents (tokens, MCP, prompts), sauf valeur
transversale (stats, sessions, secrets, coûts).
---
## 11. 🏁 Par où commencer
*Le suivi est en place sur Gitea : 4 milestones (v0.3.0 → v1.0), 22 labels
(P0–P3, axe-1…axe-12, S/M/L/XL, quick-win, epic) et les issues ci-dessous.
Chaque PR doit référencer son issue avec `closes #N` pour une clôture
automatique. Ordre de construction proposé pour la phase 0 — chaque étape
est un PR indépendant et testé :*
1. **[PR 1](https://git.dracodev.net/Projets/agent-manager/issues/2)** — src/events.rs : journal JSONL + rotation + tests.
2. **[PR 2](https://git.dracodev.net/Projets/agent-manager/issues/3)** — instrumentation start/stop/run (process.rs, run_cmd.rs) — dépend de PR 1.
3. **[PR 3](https://git.dracodev.net/Projets/agent-manager/issues/4)** — state.json v2 + migration additive — dépend de PR 1.
4. **[PR 4](https://git.dracodev.net/Projets/agent-manager/issues/5)** — sessions.json + am sessions + réconciliation — dépend de PR 2.
5. **[PR 5](https://git.dracodev.net/Projets/agent-manager/issues/6)** — am stats + am log (lecture du journal) — dépend de PR 2.
6. **[PR 6](https://git.dracodev.net/Projets/agent-manager/issues/7)** — historique structuré + am history (migration de history.txt).
7. **[PR 7](https://git.dracodev.net/Projets/agent-manager/issues/8)** — am init + détection de contexte (git, stack, cache) — dépend de PR 1.
8. **En parallèle** — les quick wins de la section 3 :
[am logs #9](https://git.dracodev.net/Projets/agent-manager/issues/9) ·
[am list --sort #10](https://git.dracodev.net/Projets/agent-manager/issues/10) ·
[am config set #11](https://git.dracodev.net/Projets/agent-manager/issues/11) ·
[am alias add #12](https://git.dracodev.net/Projets/agent-manager/issues/12) ·
[dedup history.txt #13](https://git.dracodev.net/Projets/agent-manager/issues/13) ·
[am doctor --json #14](https://git.dracodev.net/Projets/agent-manager/issues/14) ·
[am version --json #15](https://git.dracodev.net/Projets/agent-manager/issues/15) ·
[am open #16](https://git.dracodev.net/Projets/agent-manager/issues/16).
Épique de suivi : [issue #1](https://git.dracodev.net/Projets/agent-manager/issues/1) (Phase 0).
---
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
Chaque item peut être chiffré et planifié indépendamment ; les phases 1 à 3
seront détaillées dans des documents dédiés au moment de leur démarrage.*
+32
View File
@@ -0,0 +1,32 @@
//! Record build metadata (git commit and branch) at compile time so the
//! 'am version' command can display it, like Nushell's version table.
//!
//! When git is unavailable (e.g. building from a source tarball) the values
//! are simply empty.
use std::process::Command;
fn git(args: &[&str]) -> String {
Command::new("git")
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/heads");
let commit = git(&["rev-parse", "HEAD"]);
let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
// A detached HEAD reports the literal branch name "HEAD".
let branch = if branch == "HEAD" {
String::new()
} else {
branch
};
println!("cargo:rustc-env=AM_GIT_COMMIT={commit}");
println!("cargo:rustc-env=AM_GIT_BRANCH={branch}");
}
+18 -5
View File
@@ -14,7 +14,7 @@ version: "1.0"
settings:
install_dir: null # default: ~/.local/share/agent-manager/agents
log_dir: null # default: ~/.local/state/agent-manager/logs
default_shell: null # default: sh on Unix
default_shell: null # install scripts + shell gateway (default: user's shell)
auto_install_deps: true # offer to install missing dependencies
confirm_before_run: true # confirm before running install scripts
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
@@ -56,6 +56,19 @@ agents:
run: claude
tags: [anthropic, assistant, source-available, mainstream]
- name: deepseek-harness
display_name: "DeepSeek Harness"
description: "CLI officiel DeepSeek (dsh) : boot de profils, gestion des plugins Cordis, orchestration multi-agents, boucles de buts (goals) et interface web. Installez-le pour piloter vos agents depuis am."
category: coding-agent
website: https://github.com/deepseek-ai/deepseek-harness
install:
type: npm
package: "@deepseek-ai/dsh"
dependencies:
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
run: dsh
tags: [deepseek, harness, multi-agent, plugins, web-ui]
- name: hermes-agent
display_name: "Hermes Agent"
description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)."
@@ -328,14 +341,14 @@ agents:
- name: kimi-cli
display_name: "Kimi CLI"
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0)."
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0). Installé via uv (paquet PyPI kimi-cli)."
category: coding-agent
website: https://github.com/MoonshotAI/kimi-cli
install:
type: npm
package: "@moonshotai/kimi-cli"
type: uv
package: kimi-cli
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
- { name: uv, min_version: "0.4.0", install_hint: "https://docs.astral.sh/uv" }
run: kimi
tags: [moonshot, skills, mcp]
+34 -1
View File
@@ -6,7 +6,9 @@ use crate::cli::Cli;
use crate::config::{self, Config, Settings};
use crate::output::Logger;
use crate::state::StateStore;
use anyhow::{Context, Result};
use crate::theme::{self, Theme};
use anyhow::{anyhow, Context, Result};
use std::cell::Cell;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
@@ -19,6 +21,9 @@ pub struct App {
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
/// Active output color theme (switchable at runtime with the REPL
/// 'theme <name>' command).
theme: Cell<&'static Theme>,
}
#[derive(Debug, Clone)]
@@ -78,6 +83,7 @@ impl App {
loaded.config.aliases.clone(),
loaded.config.groups.clone(),
);
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
Ok(Self {
cli,
config: loaded.config,
@@ -86,6 +92,7 @@ impl App {
catalog,
state,
log,
theme: Cell::new(theme),
})
}
@@ -105,6 +112,15 @@ impl App {
!self.cli.no_color && std::io::stdout().is_terminal()
}
pub fn theme(&self) -> &'static Theme {
self.theme.get()
}
/// Switch the active theme (used by the REPL 'theme <name>' command).
pub fn set_theme(&self, theme: &'static Theme) {
self.theme.set(theme);
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes {
@@ -119,6 +135,23 @@ impl App {
}
}
/// Resolve the output color theme: the --theme flag wins, then the
/// configuration entry, then the default theme. An unknown --theme value is
/// an error; an unknown configured value silently falls back to the default.
fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> {
if let Some(name) = flag {
return theme::find(name).ok_or_else(|| {
anyhow!(
"unknown theme '{name}' — available: {}",
theme::names().join(", ")
)
});
}
Ok(configured
.and_then(theme::find)
.unwrap_or_else(theme::default_theme))
}
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
/// mostly used by tests).
pub fn data_dir() -> PathBuf {
+24 -1
View File
@@ -10,6 +10,9 @@ use std::path::PathBuf;
name = "am",
bin_name = "am",
version,
// The help subcommand is provided by the custom Nushell-style help
// (cli::Command::Help) instead of clap's generated one.
disable_help_subcommand = true,
about = "agent-manager (am) — manage local AI coding agents",
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
@@ -47,6 +50,10 @@ pub struct Cli {
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_color: bool,
/// Color theme for the output (see 'am help theme' for the list)
#[arg(long, global = true, value_name = "THEME")]
pub theme: Option<String>,
#[command(subcommand)]
pub command: Option<Command>,
}
@@ -102,7 +109,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,
@@ -131,6 +138,16 @@ pub enum Command {
/// Agent name or alias
agent: String,
},
/// Display help: the overview, a command, the command list, or a search
Help {
/// Command name (also accepts an agent name or alias); "commands" lists every command
command: Option<String>,
/// Search through all help commands table
#[arg(long, value_name = "TEXT")]
find: Option<String>,
},
/// Display version and build information
Version,
/// Manage the configuration file
#[command(subcommand)]
Config(ConfigCmd),
@@ -163,6 +180,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)
+16 -7
View File
@@ -2,7 +2,7 @@
use super::*;
use crate::doctor::{self, Status};
use crate::output::{print_json, status_style, Table};
use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
use anyhow::Result;
@@ -27,16 +27,25 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
return Ok(if report.ok() { 0 } else { 1 });
}
let color = app.color();
let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]);
let theme = app.theme();
let mut table_rows = Vec::new();
for c in &report.checks {
let status = match c.status {
Status::Ok => status_style("ok", color),
Status::Warn => status_style("warn", color),
Status::Error => status_style("error", color),
Status::Ok => status_style("ok", theme, color),
Status::Warn => status_style("warn", theme, color),
Status::Error => status_style("error", theme, color),
};
table.row(vec![c.name.clone(), status, c.detail.clone()]);
table_rows.push(vec![c.name.clone(), status, c.detail.clone()]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["CHECK", "STATUS", "DETAIL"],
table_rows,
theme,
color,
)
);
if report.problems() > 0 {
app.log
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
+12
View File
@@ -0,0 +1,12 @@
//! help: Nushell-style help — the overview, one command, the command list,
//! or a keyword search through it.
use super::*;
pub fn run(app: &App, command: Option<&str>, find: Option<&str>) -> Result<i32> {
if let Some(text) = find {
crate::help::print_find(app, text);
return Ok(0);
}
crate::help::print_topic(app, command)
}
+134 -48
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::output::print_json;
use colored::Colorize;
use serde::Serialize;
#[derive(Serialize)]
@@ -101,51 +102,102 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
return Ok(0);
}
println!("{}", agent.title());
if agent.name != agent.title() {
println!(" slug: {}", agent.name);
}
if let Some(d) = &agent.description {
println!(" description: {d}");
}
let color = app.color();
let dim = |s: &str| -> String {
if color {
s.dimmed().to_string()
} else {
s.to_string()
}
};
let bold = |s: &str| -> String {
if color {
s.bold().to_string()
} else {
s.to_string()
}
};
// --- Header -------------------------------------------------------------
println!("{}", bold(agent.title()));
let mut sub: Vec<String> = vec![agent.name.clone()];
if let Some(c) = &agent.category {
println!(" category: {c}");
sub.push(c.clone());
}
println!(" {}", dim(&sub.join(" · ")));
if let Some(d) = &agent.description {
println!();
println!(" {d}");
}
if let Some(w) = &agent.website {
println!(" website: {w}");
}
if !agent.tags.is_empty() {
println!(" tags: {}", agent.tags.join(", "));
}
if !agent.platforms.is_empty() {
println!(" platforms: {}", agent.platforms.join(", "));
println!();
println!(" {} {}", dim("website"), w);
}
println!();
// Status block
let status = if running {
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
// --- Status --------------------------------------------------------------
let external = external_path(agent);
let (status_word, detail): (&str, String) = if running {
let pid = entry.as_ref().and_then(|e| e.pid).unwrap_or(0);
("running", format!(" (pid {pid})"))
} else if entry.is_some() {
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
let v = entry
.as_ref()
.and_then(|e| e.version.clone())
.map(|v| format!(" — v{v}"))
.unwrap_or_default();
("installed", v)
} else if !agent.installable {
"not installable locally".to_string()
} else if external_path(agent).is_some() {
"found on PATH (external — not managed)".to_string()
("not-installable", String::new())
} else if external.is_some() {
("external", " (found on PATH — not managed)".to_string())
} else {
"not installed".to_string()
("not-installed", String::new())
};
println!(" status: {status}");
let word = crate::output::status_style(status_word, app.theme(), color);
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
// Version: recorded for managed installs, detected for external ones.
let version_display: Option<String> = entry
.as_ref()
.and_then(|e| e.version.clone())
.or_else(|| external.as_ref().and_then(|p| crate::probe::detect_external_version(p)));
if let Some(v) = version_display {
println!(" {} {}", dim("● version"), v);
}
if let Some(t) = entry.as_ref().map(|e| e.installed_at.clone()) {
println!(" {} {}", dim("● installed"), t);
}
if let Some(t) = entry.as_ref().and_then(|e| e.updated_at.clone()) {
println!(" {} {}", dim("● updated"), t);
}
if let Some(p) = entry
.as_ref()
.map(|e| e.install_dir.clone())
.or_else(|| external.clone().map(|p| p.display().to_string()))
{
println!(" {} {}", dim("● path"), p);
}
if let Some(e) = &entry {
if let Some(bin) = e.bins.first() {
println!(" {} {}", dim("● bin"), bin);
}
}
// --- How to run it -------------------------------------------------------
if let Some(run) = &agent.run {
let args = if agent.args.is_empty() {
String::new()
} else {
format!(" {}", agent.args.join(" "))
};
println!(" run: {run}{args}");
println!();
println!(" {} {}", dim("▶ run"), format!("{run}{args}"));
}
if !agent.env.is_empty() {
println!(
" env: {}",
" {} {}",
dim("⚙ env"),
agent
.env
.iter()
@@ -155,38 +207,72 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
);
}
// --- Installation ---------------------------------------------------------
if let Some(spec) = &agent.install {
println!(" install methods:");
println!();
println!(" {}", bold("install"));
for (i, m) in spec.all_methods().iter().enumerate() {
let note = m
.note
.as_deref()
.map(|n| format!(" ({n})"))
.unwrap_or_default();
println!(" {}) {}{}", i + 1, describe_method(m), note);
println!(" {}) {}", i + 1, describe_method(m));
if let Some(n) = &m.note {
println!(" {}", dim(n));
}
}
if !spec.post_install.is_empty() {
println!(" post-install: {}", spec.post_install.join(" && "));
println!(
" {}",
dim(&format!("post-install: {}", spec.post_install.join(" && ")))
);
}
}
// --- Dependencies, checked live ------------------------------------------
if !agent.dependencies.is_empty() {
println!(" dependencies:");
for d in &agent.dependencies {
let min = d
.min_version
.as_deref()
.map(|m| format!(" >= {m}"))
.unwrap_or_default();
let hint = d
.install_hint
.as_deref()
.map(|h| format!(" — {h}"))
.unwrap_or_default();
println!(" - {}{}{}", d.name, min, hint);
println!();
println!(" {}", bold("dependencies"));
let runner = crate::runner::SystemRunner::new(false, app.cli.verbose, &app.log);
let checks = crate::deps::check_agent(agent, &runner);
for c in &checks {
let mark = if c.ok { "✓" } else { "✗" };
let mark = if color {
if c.ok {
mark.green().to_string()
} else {
mark.red().to_string()
}
} else {
mark.to_string()
};
let mut line = format!(" {mark} {}", c.name);
if let Some(v) = &c.version {
line.push_str(&format!(" {v}"));
}
if let Some(min) = &c.min {
line.push_str(&format!(" (min {min})"));
}
if !c.ok {
if let Some(hint) = agent
.dependencies
.iter()
.find(|d| d.name == c.name)
.and_then(|d| d.install_hint.clone())
{
line.push_str(&format!(" — {hint}"));
} else if !c.detail.is_empty() && c.detail != "not found on PATH" {
line.push_str(&format!(" — {}", c.detail));
}
}
println!("{line}");
}
}
// --- Tags and note ---------------------------------------------------------
if !agent.tags.is_empty() {
println!();
println!(" {}", dim(&format!("tags {}", agent.tags.join(" · "))));
}
if let Some(n) = &agent.note {
println!(" note: {n}");
println!();
println!(" {}", dim(&format!("note {n}")));
}
Ok(0)
}
+15 -5
View File
@@ -2,7 +2,7 @@
//! the whole catalog with --all.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::output::{boxed_table, print_json, status_style};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
@@ -50,17 +50,27 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "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"
+6
View File
@@ -4,15 +4,18 @@ pub mod completion_cmd;
pub mod config_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod help_cmd;
pub mod info_cmd;
pub mod install_cmd;
pub mod list_cmd;
pub mod run_cmd;
pub mod search_cmd;
pub mod self_uninstall;
pub mod self_update;
pub mod status_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod version_cmd;
use crate::app::App;
use crate::cli::Command;
@@ -54,11 +57,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app),
Command::Config(sub) => config_cmd::run(app, sub),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
Command::Run { agent, args } => run_cmd::run(app, agent, args),
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),
}
+12 -4
View File
@@ -1,7 +1,7 @@
//! search: keyword search over the catalog.
use super::*;
use crate::output::{print_json, Table};
use crate::output::{boxed_table, print_json};
use serde::Serialize;
#[derive(Serialize)]
@@ -33,17 +33,25 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
print_json(&rows);
return Ok(0);
}
let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]);
let mut table_rows = Vec::new();
for a in hits {
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
table.row(vec![
table_rows.push(vec![
a.title().to_string(),
a.category.clone().unwrap_or_else(|| "—".to_string()),
if a.installable { "yes" } else { "no" }.to_string(),
desc,
]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"],
table_rows,
app.theme(),
app.color(),
)
);
if app.catalog.search(keyword, category).is_empty() {
app.log
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
+386
View File
@@ -0,0 +1,386 @@
//! self-uninstall: remove agent-manager and everything it created from this
//! machine — background agents are stopped first, then the data, state and
//! configuration directories are deleted, then the executable itself.
use crate::app::{self, App};
use anyhow::{bail, Result};
#[cfg(unix)]
use anyhow::Context;
use colored::Colorize;
use std::path::{Path, PathBuf};
#[cfg(windows)]
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
#[cfg(windows)]
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
#[cfg(windows)]
const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFF_FFFF;
#[cfg(windows)]
#[link(name = "Kernel32")]
extern "system" {
fn MoveFileExW(
lpExistingFileName: *const u16,
lpNewFileName: *const u16,
dwFlags: u32,
) -> i32;
fn GetFileAttributesW(lpFileName: *const u16) -> u32;
fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32;
}
pub fn run(app: &App) -> Result<i32> {
// 1. Stop every managed background agent.
let state = app.state.load()?;
for (name, entry) in &state.installed {
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log.info(&format!("stopping agent '{name}' (pid {pid})"));
crate::process::stop_pid(pid, false, 1);
}
}
}
// 2. Collect the directories agent-manager owns.
let data_root = app::data_dir();
let state_root = app::state_dir();
let mut roots: Vec<(&str, PathBuf)> = Vec::new();
if data_root.is_dir() {
roots.push(("data", data_root.clone()));
}
if state_root != data_root && state_root.is_dir() {
roots.push(("state", state_root));
}
if let Some(dir) = crate::config::user_config_dir() {
if dir.is_dir() {
roots.push(("config", dir));
}
}
let exe = std::env::current_exe().ok();
let mut renamed: Option<PathBuf> = None;
if roots.is_empty() {
app.log
.info("nothing to remove — agent-manager left no traces on this machine");
} else {
app.log.info("agent-manager will remove:");
for (label, path) in &roots {
println!(" {label}: {}", path.display());
}
if !app.confirm("Remove everything listed above?")? {
app.log.info("cancelled — nothing was removed");
return Ok(0);
}
if app.dry_run() {
app.log.dry("nothing removed (dry run)");
return Ok(0);
}
// Windows allows renaming a running executable (but not deleting
// it): do it up-front so the original binary disappears immediately,
// even if the deferred deletion has to wait for this process to exit.
renamed = exe.as_deref().and_then(rename_for_deletion);
if let Some(new) = &renamed {
if let Some(old) = &exe {
println!(
"{} {} -> {}",
"renamed:".cyan(),
old.display(),
new.display()
);
}
}
// Remove everything first; any message logged *after* deleting the
// state directory would recreate the log folder, so the removal
// itself is silent and the report below goes to plain stdout.
// The removal is tolerant: read-only attributes are cleared and files
// locked by other processes (for example the running am.exe when it
// lives inside the data directory) are scheduled for deletion at the
// next reboot instead of failing the whole uninstall.
let mut locked: Vec<PathBuf> = Vec::new();
for (_, path) in &roots {
remove_tree(path, &mut locked);
}
for (_, path) in &roots {
if !path.exists() {
println!("{} {}", "removed:".green(), path.display());
}
}
if !locked.is_empty() {
let scheduled = schedule_locked(&locked);
if !scheduled.is_empty() {
println!(
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
"locked:".yellow(),
scheduled.len()
);
}
if scheduled.len() != locked.len() {
println!(
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
"deferred:".cyan(),
locked.len() - scheduled.len()
);
}
}
}
// 3. Delete the executable itself, then sweep the directories left
// empty behind it.
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
match &exe {
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
None => app.log
.warn("cannot locate the agent-manager executable — delete it manually"),
}
Ok(0)
}
/// Recursively remove a tree, tolerating locked or read-only entries.
/// Paths that could not be removed are collected into `locked` so the
/// caller can schedule their deletion at the next reboot.
fn remove_tree(path: &Path, locked: &mut Vec<PathBuf>) {
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(_) => return,
};
if meta.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
remove_tree(&entry.path(), locked);
}
}
clear_readonly(path);
if std::fs::remove_dir(path).is_err() {
locked.push(path.to_path_buf());
}
} else {
clear_readonly(path);
if std::fs::remove_file(path).is_err() {
locked.push(path.to_path_buf());
}
}
}
/// Clear the read-only attribute (a common cause of "Access denied" on
/// Windows, e.g. files extracted by Expand-Archive).
#[cfg(windows)]
fn clear_readonly(path: &Path) {
use std::os::windows::ffi::OsStrExt;
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let attrs = GetFileAttributesW(wide.as_ptr());
if attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY) != 0 {
SetFileAttributesW(wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
}
}
}
#[cfg(not(windows))]
fn clear_readonly(_path: &Path) {}
/// Schedule every locked path for deletion at the next reboot (Windows).
/// Returns the paths that could be scheduled.
#[cfg(windows)]
fn schedule_locked(locked: &[PathBuf]) -> Vec<PathBuf> {
use std::os::windows::ffi::OsStrExt;
let mut scheduled = Vec::new();
for path in locked {
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let ok = unsafe {
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
};
if ok {
scheduled.push(path.clone());
}
}
scheduled
}
#[cfg(not(windows))]
fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
Vec::new()
}
/// Delete the running executable. On Unix the file can be unlinked while it
/// runs; on Windows a hidden cmd re-tries the deletion right after this
/// process exits (the file is locked while we run). Empty directories left
/// behind under the removed roots are swept afterwards.
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
#[cfg(unix)]
{
std::fs::remove_file(exe)
.with_context(|| format!("cannot remove {}", exe.display()))?;
// Sweep empty directories upward, but never above a removed root.
let mut dir = exe.parent();
while let Some(d) = dir {
if !roots.iter().any(|r| d.starts_with(r)) {
break;
}
if std::fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
println!(
"{} {}",
"agent-manager removed:".green(),
exe.display()
);
return Ok(());
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
// The file to delete once this process exits: the renamed copy when
// the up-front rename worked, the original path otherwise.
let target = renamed.unwrap_or(exe);
// A temporary batch file retries the deletion for a few seconds (the
// image stays locked until we exit), sweeps the directories left
// empty, then deletes itself. A .bat avoids every cmd /C command-line
// quoting pitfall.
let bat = write_cleanup_bat(exe, target, roots);
if let Some(b) = &bat {
// raw_arg passes the command line verbatim: regular .arg()
// backslash-escapes embedded quotes, which cmd.exe does not
// understand.
use std::os::windows::process::CommandExt as _;
let mut cleanup = std::process::Command::new("cmd");
cleanup.raw_arg("/C");
cleanup.raw_arg(format!("\"{}\"", b.display()));
let _ = cleanup.spawn();
}
// Belt and braces: if something keeps the file locked, the standard
// uninstaller mechanism deletes it at the next reboot.
let wide: Vec<u16> = target
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let at_reboot = unsafe {
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
};
if bat.is_none() && !at_reboot {
bail!(
"cannot schedule deletion of {} — delete it manually",
exe.display()
);
}
if renamed.is_some() {
println!(
"{} {}",
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
exe.display()
);
} else {
println!(
"{} {}",
"agent-manager removed; the executable will be deleted momentarily:".green(),
exe.display()
);
}
return Ok(());
}
#[cfg(not(any(unix, windows)))]
{
bail!(
"automatic removal of the executable is not supported on this platform; delete {} manually",
exe.display()
);
}
}
/// Rename the running executable out of the way (Windows allows renaming a
/// running image — only deletion is refused while it executes). Returns the
/// new path on success.
#[cfg(windows)]
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
let name = exe.file_name()?.to_string_lossy();
let new = exe.with_file_name(format!("{name}.deleteme"));
std::fs::rename(exe, &new).ok()?;
Some(new)
}
#[cfg(not(windows))]
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
None
}
/// Write a temporary batch file that deletes the target executable once it
/// is unlocked, sweeps the directories left empty and then removes itself.
#[cfg(windows)]
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
let crlf = "\r\n";
let q = "\"";
let mut script = String::new();
let mut line = |s: &str| {
script.push_str(s);
script.push_str(crlf);
};
line("@echo off");
line("set n=0");
line(":retry");
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
line(&format!(
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
target.display(),
exe.display()
));
line("set /a n+=1");
line("if %n% GEQ 60 goto end");
line("ping -n 2 127.0.0.1 >nul");
line("goto retry");
line(":sweep");
if let Some(parent) = exe.parent() {
if roots.iter().any(|r| parent.starts_with(r)) {
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
}
}
for root in roots {
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
}
line(":end");
line(&format!("del {q}%~f0{q} >nul 2>&1"));
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
std::fs::write(&path, script).ok()?;
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_readonly_tree() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
let file = sub.join("readonly.txt");
std::fs::write(&file, "x").unwrap();
// Read-only files are a classic cause of "Access denied" on Windows.
let mut perms = std::fs::metadata(&file).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(true);
std::fs::set_permissions(&file, perms).unwrap();
let mut locked: Vec<PathBuf> = Vec::new();
remove_tree(dir.path(), &mut locked);
assert!(!dir.path().exists(), "tree should be fully removed");
assert!(locked.is_empty(), "unexpected locked paths: {locked:?}");
}
#[test]
fn tolerates_missing_tree() {
let mut locked: Vec<PathBuf> = Vec::new();
remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked);
assert!(locked.is_empty());
}
}
+41 -18
View File
@@ -1,7 +1,7 @@
//! status: detailed state of one agent, or of every installed agent.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::output::{boxed_table, kv_table, print_json, status_style};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
@@ -39,28 +39,42 @@ fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result<i32> {
return Ok(0);
}
let color = app.color();
let theme = app.theme();
println!("{} ({})", agent.title(), agent.name);
let status_line = status_style(&detail.status, color);
println!(" status: {status_line}");
println!(
" version: {}",
detail.version.as_deref().unwrap_or("unknown")
);
println!(" method: {}", detail.method.as_deref().unwrap_or("—"));
println!(" path: {}", detail.path.as_deref().unwrap_or("—"));
println!(" run: {}", detail.run.as_deref().unwrap_or("—"));
let status_line = status_style(&detail.status, theme, color);
let mut rows: Vec<(String, String)> = vec![
("agent".to_string(), agent.name.clone()),
("status".to_string(), status_line),
(
"version".to_string(),
detail.version.clone().unwrap_or_else(|| "unknown".to_string()),
),
(
"method".to_string(),
detail.method.clone().unwrap_or_else(|| "—".to_string()),
),
(
"path".to_string(),
detail.path.clone().unwrap_or_else(|| "—".to_string()),
),
(
"run".to_string(),
detail.run.clone().unwrap_or_else(|| "—".to_string()),
),
];
if let Some(pid) = detail.pid {
println!(" pid: {pid}");
rows.push(("pid".to_string(), pid.to_string()));
}
if let Some(t) = &detail.started_at {
println!(" started: {t}");
rows.push(("started".to_string(), t.clone()));
}
if let Some(t) = &detail.installed_at {
println!(" installed: {t}");
rows.push(("installed".to_string(), t.clone()));
}
if let Some(l) = &detail.log {
println!(" log: {l}");
rows.push(("log".to_string(), l.clone()));
}
print!("{}", kv_table(&rows, theme, color));
Ok(0)
}
@@ -90,17 +104,26 @@ fn status_all(app: &App, probe: &ExternalInfo) -> Result<i32> {
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]);
let theme = app.theme();
let mut table_rows = Vec::new();
for r in &rows {
table.row(vec![
table_rows.push(vec![
r.display_name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
status_style(&r.status, theme, color),
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
print!(
"{}",
boxed_table(
vec!["NAME", "VERSION", "STATUS", "PID", "PATH"],
table_rows,
theme,
color,
)
);
if rows.is_empty() {
app.log.info("no installed or running agents");
}
+206 -5
View File
@@ -1,19 +1,19 @@
//! 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 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.
@@ -65,6 +65,130 @@ 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.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 +235,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());
}
}
+15
View File
@@ -0,0 +1,15 @@
//! version: version and build information, Nushell-style.
use super::*;
use crate::help::{version_info, version_table};
use crate::output::print_json;
pub fn run(app: &App) -> Result<i32> {
let info = version_info();
if app.json() {
print_json(&info);
return Ok(0);
}
print!("{}", version_table(&info, app.theme(), app.color()));
Ok(0)
}
+7 -1
View File
@@ -50,7 +50,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 +66,10 @@ pub struct Settings {
/// GitHub repository (owner/repo) used by the self-update command.
#[serde(default)]
pub self_update_repo: Option<String>,
/// Color theme for the output (see 'am help theme' for the list; the
/// --theme flag overrides it).
#[serde(default)]
pub theme: Option<String>,
}
impl Settings {
@@ -623,6 +628,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));
}
+7 -13
View File
@@ -151,19 +151,13 @@ pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<(
let proceed = app
.confirm(&format!("Run the detected installer for {}? -> {cmd}", os.name))?;
if proceed {
if let Ok(tokens) = shell_words::split(cmd) {
if !tokens.is_empty() {
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
if out.code != 0 {
app.log.warn(&format!(
"installation of '{}' failed (exit code {})",
dep.name, out.code
));
}
}
let code = crate::toolchain::run_install_command(runner, cmd)?;
crate::toolchain::refresh_path();
if code != 0 {
app.log.warn(&format!(
"installation of '{}' failed (exit code {code})",
dep.name
));
}
}
}
+15 -16
View File
@@ -65,7 +65,12 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
];
let os = crate::toolchain::OsInfo::detect();
for (tool, required) in tools {
match runner.which(tool) {
let found = if *tool == "python" {
runner.which("python3").or_else(|| runner.which("python"))
} else {
runner.which(tool)
};
match found {
Some(path) => {
let mut detail = path.display().to_string();
let empty = BTreeMap::new();
@@ -211,8 +216,8 @@ fn is_on_path(dir: &std::path::Path) -> bool {
pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result<Vec<String>> {
let mut installed = Vec::new();
let os = crate::toolchain::OsInfo::detect();
for tool in ["node", "python", "cargo", "go", "bun", "uv", "docker"] {
if runner.which(tool).is_some() {
for tool in ["git", "node", "python", "cargo", "go", "bun", "uv", "docker"] {
if crate::toolchain::tool_is_present(tool, runner) {
continue;
}
let cmds = crate::toolchain::install_commands(tool, &os);
@@ -225,19 +230,13 @@ pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result<Vec<Strin
))? {
continue;
}
if let Ok(tokens) = shell_words::split(cmd) {
if !tokens.is_empty() {
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
installed.push(if out.code == 0 {
format!("installed '{tool}' ({cmd})")
} else {
format!("failed to install '{tool}' (exit code {})", out.code)
});
}
}
let code = crate::toolchain::run_install_command(runner, cmd)?;
crate::toolchain::refresh_path();
installed.push(if code == 0 {
format!("installed '{tool}' ({cmd})")
} else {
format!("failed to install '{tool}' (exit code {code})")
});
}
Ok(installed)
}
+255 -38
View File
@@ -139,23 +139,133 @@ pub struct GhRelease {
pub assets: Vec<GhAsset>,
}
/// Authorization header for api.github.com when GITHUB_TOKEN or GH_TOKEN
/// is set (raises the rate limit from 60 to 5000 requests/hour).
fn github_auth_header() -> Option<String> {
let token = std::env::var("GITHUB_TOKEN")
.or_else(|_| std::env::var("GH_TOKEN"))
.ok()
.filter(|t| !t.trim().is_empty())?;
Some(format!("Bearer {}", token.trim()))
}
pub fn latest_release(repo: &str) -> Result<GhRelease> {
match latest_release_api(repo) {
Ok(release) => Ok(release),
Err(e) if format!("{e:#}").contains("rate limit") => {
// The API is rate limited (60 unauthenticated requests/hour):
// fall back to the GitHub web pages, which are not limited.
latest_release_web(repo)
}
Err(e) => Err(e),
}
}
fn latest_release_api(repo: &str) -> Result<GhRelease> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let resp = ureq::get(&url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow!("no GitHub releases found for repository '{repo}'")
}
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
})?;
let mut req = ureq::get(&url).set("User-Agent", USER_AGENT);
if let Some(auth) = github_auth_header() {
req = req.set("Authorization", &auth);
}
let resp = req.call().map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow!("no GitHub releases found for repository '{repo}'")
}
ureq::Error::Status(403, _) => anyhow!(
"GitHub API request failed for '{repo}': rate limit exceeded — set the GITHUB_TOKEN environment variable to raise the limit, or retry in a few minutes"
),
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
})?;
let text = resp
.into_string()
.context("reading GitHub API response")?;
serde_json::from_str::<GhRelease>(&text).context("parsing GitHub API response")
}
/// Discover the latest release without the API: the /releases/latest page
/// redirects to the tagged page (tag from the final URL), then the
/// expanded_assets endpoint lists every asset. These web pages are not
/// subject to the API rate limit.
fn latest_release_web(repo: &str) -> Result<GhRelease> {
let latest = format!("https://github.com/{repo}/releases/latest");
let resp = ureq::get(&latest)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| anyhow!("cannot reach GitHub releases page for '{repo}': {e}"))?;
let final_url = resp.get_url().to_string();
let tag = final_url
.rsplit('/')
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty() && final_url.contains("/releases/tag/"))
.ok_or_else(|| {
anyhow!("no GitHub releases found for repository '{repo}' (no tagged release page)")
})?;
let assets_url = format!("https://github.com/{repo}/releases/expanded_assets/{tag}");
let resp = ureq::get(&assets_url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| anyhow!("cannot list GitHub release assets for '{repo}': {e}"))?;
let html = resp
.into_string()
.context("reading GitHub expanded_assets response")?;
let mut assets = parse_expanded_assets(&html, repo, &tag);
if assets.is_empty() {
anyhow::bail!(
"GitHub release '{repo}@{tag}' has no downloadable assets (web fallback)"
);
}
assets.sort_by(|a, b| a.name.cmp(&b.name));
assets.dedup_by(|a, b| a.name == b.name);
Ok(GhRelease {
tag_name: tag,
assets,
})
}
/// Extract the asset links from a GitHub expanded_assets HTML fragment.
fn parse_expanded_assets(html: &str, repo: &str, tag: &str) -> Vec<GhAsset> {
let marker = format!("/{repo}/releases/download/");
let mut assets = Vec::new();
let mut rest = html;
while let Some(pos) = rest.find(&marker) {
let start = pos + marker.len();
let end = rest[start..]
.find(['"', '\'', '<'])
.map(|i| start + i)
.unwrap_or(rest.len());
let raw = &rest[start..end];
// The href reads "download/<tag>/<asset-name>": keep the name only.
let raw = raw.strip_prefix(&format!("{tag}/")).unwrap_or(raw);
// GitHub sometimes hides zero-width characters inside hrefs.
let name = raw.replace('\u{200b}', "");
let name = decode_html_entities(&name);
if !name.is_empty() {
assets.push(GhAsset {
name: name.clone(),
browser_download_url: format!(
"https://github.com/{repo}/releases/download/{tag}/{name}"
),
size: 0,
});
}
rest = &rest[end..];
}
assets
}
fn decode_html_entities(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&#34;", "\"")
.replace("&#32;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&nbsp;", " ")
}
pub struct PickedAsset {
pub name: String,
pub url: String,
@@ -281,6 +391,14 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
if lower.contains("static") || lower.contains("portable") {
s += 1;
}
// Prefer plain archives (extracted to a single executable) over
// installer/setup programs, which cannot be used directly.
if lower.ends_with(".zip") || lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
s += 2;
}
if lower.contains("setup") || lower.contains("installer") || lower.contains("-install") {
s = s.saturating_sub(3);
}
if lower.contains("armv7")
|| lower.contains("armhf")
|| lower.contains("armv6")
@@ -296,41 +414,102 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
// Archives and executables
// ---------------------------------------------------------------------------
/// Extract zip / tar.gz / tgz / tar / gz archives. Returns false when the
/// file is not a recognized archive (caller treats it as a plain binary).
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
/// What kind of archive a file is, sniffed from its content.
enum ArchiveKind {
Zip,
TarGz,
Tar,
Gz,
Plain,
}
/// Detect the archive type from the file's magic bytes, falling back to the
/// file name. Downloads are stored under a generic name ("download"), so
/// content sniffing is the reliable way to recognize a zip/tar.gz asset.
fn sniff_archive(path: &Path) -> Result<ArchiveKind> {
let mut head = [0u8; 512];
let n = std::fs::File::open(path)
.and_then(|mut f| std::io::Read::read(&mut f, &mut head))
.unwrap_or(0);
if n >= 4 && &head[0..4] == b"PK\x03\x04" {
return Ok(ArchiveKind::Zip);
}
if n >= 2 && head[0] == 0x1f && head[1] == 0x8b {
// gzip: peek at the decompressed stream to see whether it is a tar.
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut inner = [0u8; 512];
let m = std::io::Read::read(&mut dec, &mut inner).unwrap_or(0);
if m >= 262 && &inner[257..262] == b"ustar" {
return Ok(ArchiveKind::TarGz);
}
return Ok(ArchiveKind::Gz);
}
if n >= 262 && &head[257..262] == b"ustar" {
return Ok(ArchiveKind::Tar);
}
// Content is not an archive: keep the name-based detection for files
// that still carry a real archive extension.
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
std::fs::create_dir_all(dest)?;
if name.ends_with(".zip") {
extract_zip(path, dest)?;
Ok(true)
} else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
let f = std::fs::File::open(path)?;
let dec = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(dec);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".tar") {
let f = std::fs::File::open(path)?;
let mut ar = tar::Archive::new(f);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".gz") {
let inner = name.trim_end_matches(".gz").to_string();
let out = dest.join(inner);
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut o = std::fs::File::create(&out)?;
std::io::copy(&mut dec, &mut o)?;
Ok(true)
} else {
Ok(false)
return Ok(ArchiveKind::Zip);
}
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
return Ok(ArchiveKind::TarGz);
}
if name.ends_with(".tar") {
return Ok(ArchiveKind::Tar);
}
if name.ends_with(".gz") {
return Ok(ArchiveKind::Gz);
}
Ok(ArchiveKind::Plain)
}
/// Extract zip / tar.gz / tgz / tar / gz archives, detected by content.
/// Returns false when the file is not a recognized archive (caller treats
/// it as a plain binary).
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
std::fs::create_dir_all(dest)?;
match sniff_archive(path)? {
ArchiveKind::Zip => {
extract_zip(path, dest)?;
Ok(true)
}
ArchiveKind::TarGz => {
let f = std::fs::File::open(path)?;
let dec = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(dec);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
}
ArchiveKind::Tar => {
let f = std::fs::File::open(path)?;
let mut ar = tar::Archive::new(f);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
}
ArchiveKind::Gz => {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
let inner = name.trim_end_matches(".gz").to_string();
let out = dest.join(inner);
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut o = std::fs::File::create(&out)?;
std::io::copy(&mut dec, &mut o)?;
Ok(true)
}
ArchiveKind::Plain => Ok(false),
}
}
@@ -436,6 +615,24 @@ mod tests {
assert!(validate_url("javascript:alert(1)").is_err());
}
#[test]
fn parses_expanded_assets_fragment() {
let html = r#"<div><a href="/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe">x</a>
<a href="/owner/repo/releases/download/v1.2.3/tool-windows-x86_64&#32;copy.zip">y</a>
<span>ignore me</span>
<a href="/owner/repo/releases/download/v1.2.3/SHA256SUMS">z</a></div>"#;
let assets = parse_expanded_assets(html, "owner/repo", "v1.2.3");
let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
assert!(names.contains(&"tool-windows-x86_64.exe"), "{names:?}");
assert!(names.contains(&"tool-windows-x86_64 copy.zip"), "{names:?}");
assert!(names.contains(&"SHA256SUMS"), "{names:?}");
assert_eq!(assets.len(), 3);
assert_eq!(
assets[0].browser_download_url,
"https://github.com/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe"
);
}
#[test]
fn sha256_verification() {
let dir = tempfile::tempdir().unwrap();
@@ -447,6 +644,26 @@ mod tests {
.unwrap_err();
}
#[test]
fn extracts_zip_detected_by_magic_bytes() {
// Downloads land under a generic name with no extension: only the
// PK magic bytes reveal that it is a zip.
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("download");
{
let f = std::fs::File::create(&payload).unwrap();
let mut zip = zip::ZipWriter::new(f);
let opts = zip::write::FileOptions::default();
zip.start_file("zeroclaw.exe", opts).unwrap();
std::io::Write::write_all(&mut zip, b"MZfake").unwrap();
zip.finish().unwrap();
}
let out = dir.path().join("out");
assert!(extract_archive(&payload, &out).unwrap());
let found = find_executable(&out, Some("zeroclaw")).unwrap();
assert!(found.ends_with("zeroclaw.exe"), "{found:?}");
}
#[test]
fn extracts_and_finds() {
let dir = tempfile::tempdir().unwrap();
+404
View File
@@ -0,0 +1,404 @@
//! Event journal: every action taken by agent-manager is appended as one
//! JSON line per event in `events-YYYYMM.jsonl` files inside the state
//! directory. The journal is the source of truth for stats, sessions and
//! project aggregation (ROADMAP.md, axe 1).
//!
//! Design rules:
//! * append-only, one compact JSON object per line;
//! * monthly rotation (file name derived from the event timestamp);
//! * corrupt lines are skipped on read (never fatal);
//! * no long locks: plain append with a short retry loop for Windows
//! file locks.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
/// Kind of event, serialized in snake_case.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Start,
Stop,
Run,
Restart,
Install,
Update,
Uninstall,
Doctor,
Config,
Repl,
Shell,
}
/// One journal entry. Optional fields are omitted from the JSON line.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// RFC 3339 timestamp (UTC), used to pick the monthly file.
pub ts: String,
pub kind: EventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
/// Variable names only — values (secrets) never enter the journal.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_keys: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_s: Option<u64>,
}
impl Event {
/// Event stamped with the current UTC time.
pub fn now(kind: EventKind) -> Self {
Self {
ts: crate::installers::now_rfc3339(),
kind,
agent: None,
pid: None,
cwd: None,
project: None,
session: None,
reason: None,
args: Vec::new(),
env_keys: Vec::new(),
exit_code: None,
duration_s: None,
}
}
pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
self.agent = Some(agent.into());
self
}
pub fn with_pid(mut self, pid: u32) -> Self {
self.pid = Some(pid);
self
}
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn with_project(mut self, project: impl Into<String>) -> Self {
self.project = Some(project.into());
self
}
pub fn with_session(mut self, session: impl Into<String>) -> Self {
self.session = Some(session.into());
self
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
pub fn with_env_keys(mut self, env_keys: Vec<String>) -> Self {
self.env_keys = env_keys;
self
}
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = Some(code);
self
}
pub fn with_duration(mut self, secs: u64) -> Self {
self.duration_s = Some(secs);
self
}
}
/// Sorted list of the variable names passed to a process. Values are never
/// logged — only the keys — so secrets stay out of the journal.
pub fn env_keys(env: &BTreeMap<String, String>) -> Vec<String> {
let mut keys: Vec<String> = env.keys().cloned().collect();
keys.sort();
keys
}
/// Month of an RFC 3339 timestamp: "2026-08-16T13:49:11Z" -> "202608".
pub fn month_of(ts: &str) -> String {
ts.chars()
.take(7)
.filter(|c| c.is_ascii_digit())
.collect()
}
/// Journal file name for a month: "202608" -> "events-202608.jsonl".
pub fn file_name_for_month(month: &str) -> String {
format!("events-{month}.jsonl")
}
/// Month part of a journal file name, or None when the name does not look
/// like a journal file ("events-202608.jsonl" -> "202608").
pub fn month_from_file_name(name: &str) -> Option<String> {
let rest = name.strip_prefix("events-")?.strip_suffix(".jsonl")?;
(rest.len() == 6 && rest.chars().all(|c| c.is_ascii_digit())).then(|| rest.to_string())
}
/// Sorted journal files of a directory, oldest first.
pub fn journal_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.and_then(month_from_file_name)
.is_some()
})
.collect()
})
.unwrap_or_default();
files.sort();
files
}
/// Parse one journal file into events; corrupt lines are skipped silently.
pub fn parse_file(path: &Path) -> Vec<Event> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<Event>(l).ok())
.collect()
}
/// Read events from a directory in chronological order, limited to the most
/// recent `limit` events when limit > 0.
pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
let mut out: Vec<Event> = Vec::new();
for f in journal_files(dir).iter().rev() {
let mut evs = parse_file(f);
evs.reverse(); // newest first within one file
out.extend(evs);
if limit > 0 && out.len() >= limit {
break;
}
}
if limit > 0 {
out.truncate(limit);
}
out.reverse(); // chronological order
out
}
/// Append-only journal living in a directory (the state directory).
pub struct EventLog {
dir: PathBuf,
/// Cached (month, path) pair to avoid re-computing the file per event.
current: Option<(String, PathBuf)>,
}
impl EventLog {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
current: None,
}
}
/// Directory the journal writes into.
pub fn dir(&self) -> &Path {
&self.dir
}
/// Path of the journal file for the current month.
pub fn current_file(&mut self) -> PathBuf {
let now = crate::installers::now_rfc3339();
self.file_for(&month_of(&now))
}
fn file_for(&mut self, month: &str) -> PathBuf {
if let Some((m, p)) = &self.current {
if m == month {
return p.clone();
}
}
let p = self.dir.join(file_name_for_month(month));
self.current = Some((month.to_string(), p.clone()));
p
}
/// Append one event as a single JSON line; returns the file written to.
/// Retries briefly when the file is locked (Windows).
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
let month = month_of(&event.ts);
let path = self.file_for(&month);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = serde_json::to_string(event)?;
let mut last_err: Option<std::io::Error> = None;
for attempt in 0..5 {
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(mut f) => {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
return Ok(path);
}
Err(e) => {
last_err = Some(e);
if attempt < 4 {
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
}
}
}
}
Err(anyhow::anyhow!(
"cannot append to journal {}: {}",
path.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_roundtrip() {
let ev = Event::now(EventKind::Start)
.with_agent("claude-code")
.with_pid(42)
.with_session("s1")
.with_env_keys(vec!["OPENAI_KEY".to_string()])
.with_args(vec!["--dangerously-skip-permissions".to_string()]);
let json = serde_json::to_string(&ev).unwrap();
assert!(!json.contains('\n'));
let back: Event = serde_json::from_str(&json).unwrap();
assert_eq!(back, ev);
assert_eq!(back.agent.as_deref(), Some("claude-code"));
assert_eq!(back.pid, Some(42));
// Absent optional fields are omitted from the line.
assert!(!json.contains("duration_s"));
assert!(!json.contains("reason"));
}
#[test]
fn env_keys_are_sorted_and_never_values() {
let mut env = BTreeMap::new();
env.insert("ZEBRA".to_string(), "secret-z".to_string());
env.insert("ALPHA".to_string(), "secret-a".to_string());
let keys = env_keys(&env);
assert_eq!(keys, vec!["ALPHA".to_string(), "ZEBRA".to_string()]);
}
#[test]
fn month_helpers() {
assert_eq!(month_of("2026-08-16T13:49:11Z"), "202608");
assert_eq!(file_name_for_month("202608"), "events-202608.jsonl");
assert_eq!(
month_from_file_name("events-202608.jsonl"),
Some("202608".to_string())
);
assert_eq!(month_from_file_name("state.json"), None);
assert_eq!(month_from_file_name("events-202608.jsonl.bak"), None);
}
#[test]
fn append_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut log = EventLog::new(dir.path());
let ev = Event::now(EventKind::Run)
.with_agent("aider")
.with_exit_code(0)
.with_duration(48);
let path = log.append(&ev).unwrap();
assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some(file_name_for_month(&month_of(&ev.ts)).as_str())
);
let read = parse_file(&path);
assert_eq!(read.len(), 1);
assert_eq!(read[0], ev);
}
#[test]
fn corrupt_lines_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let mut log = EventLog::new(dir.path());
let ev = Event::now(EventKind::Doctor);
log.append(&ev).unwrap();
// Corrupt the file with garbage lines around a valid one.
let path = log.current_file();
let mut text = std::fs::read_to_string(&path).unwrap();
text.push_str("ceci n'est pas du json
");
text.push_str("
");
std::fs::write(&path, text).unwrap();
let read = parse_file(&path);
assert_eq!(read.len(), 1);
assert_eq!(read[0].kind, EventKind::Doctor);
}
#[test]
fn read_events_spans_months_and_respects_limit() {
let dir = tempfile::tempdir().unwrap();
// Two monthly files, one unrelated file.
std::fs::write(
dir.path().join("events-202607.jsonl"),
r#"{"ts":"2026-07-10T10:00:00Z","kind":"install","agent":"a"}"#,
)
.unwrap();
std::fs::write(
dir.path().join("events-202608.jsonl"),
concat!(
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"b"}"#,
"\n",
r#"{"ts":"2026-08-02T10:00:00Z","kind":"stop","agent":"b"}"#,
"\n",
),
)
.unwrap();
std::fs::write(dir.path().join("state.json"), "{}").unwrap();
let files = journal_files(dir.path());
assert_eq!(files.len(), 2);
assert!(files[0].to_string_lossy().ends_with("events-202607.jsonl"));
let all = read_events(dir.path(), 0);
assert_eq!(all.len(), 3);
assert_eq!(all[0].ts, "2026-07-10T10:00:00Z");
assert_eq!(all[2].ts, "2026-08-02T10:00:00Z");
let limited = read_events(dir.path(), 2);
assert_eq!(limited.len(), 2);
assert_eq!(limited[0].ts, "2026-08-01T10:00:00Z");
assert_eq!(limited[1].ts, "2026-08-02T10:00:00Z");
}
}
+1392
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -76,6 +76,9 @@ pub fn install(
format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display())
})?;
}
// Guard against installing something that is not an executable for this
// platform (wrong architecture, an unextracted archive, ...).
verify_executable(&dest_bin)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -88,6 +91,49 @@ pub fn install(
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_executable_accepts_pe_and_rejects_elf() {
let dir = tempfile::tempdir().unwrap();
let pe = dir.path().join("a.exe");
std::fs::write(&pe, b"MZwhatever").unwrap();
assert!(verify_executable(&pe).is_ok());
let elf = dir.path().join("b");
std::fs::write(&elf, b"ELF").unwrap();
let err = verify_executable(&elf).unwrap_err();
assert!(err.to_string().contains("not a"), "{err}");
}
}
/// Check that the downloaded file is an executable of this platform.
/// Detects the classic Windows "not compatible" (error 216) and Linux
/// "exec format error" failures before the agent is ever started.
fn verify_executable(path: &std::path::Path) -> Result<()> {
let mut head = [0u8; 4];
let n = std::fs::File::open(path)
.and_then(|mut f| std::io::Read::read(&mut f, &mut head))
.unwrap_or(0);
#[cfg(windows)]
let ok = n >= 2 && &head[0..2] == b"MZ";
#[cfg(target_os = "macos")]
let ok = (n >= 4 && (&head[0..4] == b"\xcf\xfa\xed\xfe" || &head[0..4] == b"\xfe\xed\xfa\xcf"))
|| (n >= 4 && &head[0..4] == b"\xca\xfe\xba\xbe");
#[cfg(all(unix, not(target_os = "macos")))]
let ok = n >= 4 && &head[0..4] == b"\x7fELF";
if ok {
return Ok(());
}
bail!(
"downloaded file {} is not a {} executable (magic bytes {:02x?}) — the release asset is probably for another platform or is an installer/archive",
path.display(),
std::env::consts::OS,
&head[..n]
)
}
/// Latest release tag (without the leading "v").
pub fn latest(
_app: &App,
+33 -15
View File
@@ -31,6 +31,10 @@ fn venv_python(venv: &Path) -> PathBuf {
}
/// Shared implementation for pip (use_uv=false) and uv (use_uv=true).
///
/// The uv path creates the venv with 'uv venv': uv downloads and manages a
/// Python interpreter itself when no working system Python exists, so uv
/// installs do not depend on a system Python at all.
pub fn install(
app: &App,
agent: &AgentDef,
@@ -42,7 +46,6 @@ pub fn install(
.package
.as_deref()
.ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?;
let python = find_python(runner)?;
let root = install_root(app, &agent.name);
let venv = root.join("venv");
ensure_dir(app, &root)?;
@@ -51,25 +54,26 @@ pub fn install(
.with_context(|| format!("cannot remove {}", venv.display()))?;
}
let empty = BTreeMap::new();
sh(
runner,
&python.display().to_string(),
&[
"-m".to_string(),
"venv".to_string(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
if use_uv {
let uv = require_tool(
runner,
"uv",
"https://docs.astral.sh/uv — e.g. 'pip install uv'",
)?;
sh(
runner,
&uv.display().to_string(),
&[
"venv".into(),
"--python".into(),
"3.13".into(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
let mut args: Vec<String> = vec![
"pip".into(),
"install".into(),
@@ -80,6 +84,20 @@ pub fn install(
args.extend(method.args.iter().cloned());
sh(runner, &uv.display().to_string(), &args, &method.env, None)?;
} else {
let python = find_python(runner)?;
sh(
runner,
&python.display().to_string(),
&[
"-m".to_string(),
"venv".to_string(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
let mut args: Vec<String> = vec![
"-m".into(),
"pip".into(),
@@ -91,7 +109,7 @@ pub fn install(
sh(runner, &vp.display().to_string(), &args, &method.env, None)?;
}
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&vbin, &expected);
let mut bins = find_bins(&venv_bin_dir(&venv), &expected);
if bins.is_empty() {
bins = find_bins(&bin_dir(app), &expected);
}
+18
View File
@@ -19,13 +19,20 @@ pub mod config;
pub mod deps;
pub mod doctor;
pub mod download;
pub mod events;
pub mod help;
pub mod installers;
pub mod nav;
pub mod output;
pub mod probe;
pub mod ps;
pub mod process;
pub mod repl;
pub mod runner;
pub mod shell;
pub mod state;
pub mod tables;
pub mod theme;
pub mod toolchain;
pub mod version;
@@ -33,6 +40,17 @@ use clap::Parser;
/// CLI entry point used by the am binary. Returns the process exit code.
pub fn main_entry() -> i32 {
// Enable ANSI escape sequences on Windows consoles (best effort): the
// colored crate and the theme palettes both emit raw VT codes.
#[cfg(windows)]
let _ = colored::control::set_virtual_terminal(true);
// Nushell-style help: 'am -h', 'am install -h', 'am ls --help', ... all
// show the custom help before clap gets a chance to parse the line.
let args: Vec<String> = std::env::args().collect();
if let Some(code) = help::intercept(&args) {
return code;
}
let cli = cli::Cli::parse();
match app::App::from_cli(cli) {
Ok(app) => match commands::execute(&app) {
+338
View File
@@ -0,0 +1,338 @@
//! Nushell-inspired navigation helpers: a boxed table listing of files and
//! directories (ls), and directory/path completion for Tab.
use crate::tables::{Cell, DataTable};
use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
/// Human-readable byte size, like Nushell ("512 B", "1.2 KB", "3.4 MB").
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
const GB: f64 = 1024.0 * 1024.0 * 1024.0;
let b = bytes as f64;
if b < KB {
format!("{bytes} B")
} else if b < MB {
format!("{:.1} KB", b / KB)
} else if b < GB {
format!("{:.1} MB", b / MB)
} else {
format!("{:.1} GB", b / GB)
}
}
/// Recursive size of a directory, tightly bounded: beyond the entry cap the
/// size is reported as unknown (None). The cap is deliberately small so a
/// listing stays snappy even when it contains huge trees.
pub fn dir_size(path: &Path) -> Option<u64> {
const CAP: usize = 500;
let mut total: u64 = 0;
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
let mut visited: usize = 0;
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).ok()?;
for entry in rd.flatten() {
visited += 1;
if visited > CAP {
return None;
}
let p = entry.path();
match std::fs::symlink_metadata(&p) {
Ok(m) if m.is_dir() => stack.push(p),
Ok(m) if m.is_file() => {
total = total.saturating_add(m.len());
}
_ => {}
}
}
}
Some(total)
}
fn modified_text(meta: &std::fs::Metadata) -> String {
meta.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Local> = t.into();
dt.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_else(|| "—".to_string())
}
fn paint(s: &str, color: bool, style: u8) -> String {
if !color {
return s.to_string();
}
match style {
1 => s.blue().bold().to_string(), // directory
2 => s.green().to_string(), // executable
3 => s.cyan().to_string(), // symlink
4 => s.red().to_string(), // broken symlink
_ => s.to_string(),
}
}
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
let _ = meta;
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref(),
Some("exe") | Some("bat") | Some("cmd") | Some("com")
)
}
}
/// One ls row: (sort key, cells). A single symlink_metadata call per entry
/// (the modification time comes from the same metadata).
fn row_for(p: &Path, color: bool) -> Option<(String, Vec<Cell>)> {
let meta = std::fs::symlink_metadata(p).ok()?;
let file_type = meta.file_type();
let name = p.file_name().and_then(|n| n.to_str())?.to_string();
let (kind, style) = if file_type.is_symlink() {
("symlink", if p.exists() { 3 } else { 4 })
} else if file_type.is_dir() {
("dir", 1)
} else if is_executable(p, &meta) {
("file", 2)
} else {
("file", 0)
};
let (size_text, size_bytes) = if file_type.is_dir() {
match dir_size(p) {
Some(s) => (format_size(s), Some(s)),
None => ("—".to_string(), None),
}
} else {
(format_size(meta.len()), Some(meta.len()))
};
let mut display = name.clone();
if file_type.is_dir() {
display.push('/');
} else if file_type.is_symlink() {
if let Ok(target) = std::fs::read_link(p) {
display.push_str(&format!(" -> {}", target.display()));
}
}
Some((
name.to_lowercase(),
vec![
Cell::str(paint(&display, color, style)),
Cell::str(kind.to_string()),
Cell::bytes(size_bytes.unwrap_or(0), size_text),
Cell::str(modified_text(&meta)),
],
))
}
/// Build the ls table (columns: name, type, size, modified). Directories
/// come first, then alphabetical order.
///
/// Rows are built in parallel: recursive directory sizes dominate the cost,
/// so a listing with many subdirectories stays fast (like Nushell, which
/// also parallelizes its listing).
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
let rd = std::fs::read_dir(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.min(8);
let chunk_size = entries.len().div_ceil(threads).max(1);
let rows: Vec<(String, Vec<Cell>)> = std::thread::scope(|scope| {
let mut handles = Vec::new();
for chunk in entries.chunks(chunk_size) {
let chunk: Vec<PathBuf> = chunk.to_vec();
handles.push(scope.spawn(move || {
chunk
.into_iter()
.filter_map(|p| row_for(&p, color))
.collect::<Vec<_>>()
}));
}
let mut out = Vec::new();
for handle in handles {
if let Ok(mut rows) = handle.join() {
out.append(&mut rows);
}
}
out
});
// Directories first, then alphabetical.
let mut rows = rows;
rows.sort_by(|a, b| {
let a_dir = a.1[1].text == "dir";
let b_dir = b.1[1].text == "dir";
b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0))
});
Ok(DataTable {
columns: vec![
"name".into(),
"type".into(),
"size".into(),
"modified".into(),
],
rows: rows.into_iter().map(|(_, r)| r).collect(),
})
}
/// Render a Nushell-style boxed table of the entries of a path (unbounded
/// width — used by tests; the REPL renders with the terminal width).
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
Ok(ls_table(path, color)?.render(color, 200))
}
/// Completion candidates for a path fragment typed after cd or ls.
///
/// The last segment is matched against the entries of the base directory;
/// directory candidates end with a separator so the next Tab dives one level
/// deeper, like Nushell. With dirs_only, files are excluded (cd).
pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec<String> {
let sep = std::path::MAIN_SEPARATOR;
let alt = if sep == '\\' { '/' } else { '\\' };
let idx = input.rfind(|c| c == sep || c == alt);
let (base_part, stem) = match idx {
Some(i) => (&input[..=i], &input[i + 1..]),
None => ("", input),
};
let use_sep = if input.contains(alt) && !input.contains(sep) {
alt
} else {
sep
};
let base: PathBuf = if base_part.starts_with('~') {
crate::config::expand_path(base_part)
} else if base_part.is_empty() {
base_dir.to_path_buf()
} else {
PathBuf::from(base_part)
};
let mut names: Vec<(String, bool)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&base) {
for entry in rd.flatten() {
let p = entry.path();
let is_dir = p.is_dir();
if dirs_only && !is_dir {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with(stem) {
continue;
}
names.push((name.to_string(), is_dir));
}
}
names.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
names
.into_iter()
.map(|(name, is_dir)| {
let mut full = format!("{base_part}{name}");
if is_dir {
full.push(use_sep);
}
full
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_sizes() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(2048), "2.0 KB");
assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB");
}
#[test]
fn dir_size_is_bounded() {
let dir = tempfile::tempdir().unwrap();
let big = dir.path().join("big");
std::fs::create_dir_all(&big).unwrap();
for i in 0..520 {
std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap();
}
// Beyond the cap the size is unknown, and the walk stays cheap.
assert_eq!(dir_size(&big), None);
let small = dir.path().join("small");
std::fs::create_dir_all(&small).unwrap();
std::fs::write(small.join("a.txt"), "hello").unwrap();
assert_eq!(dir_size(&small), Some(5));
}
#[test]
fn completes_directories_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join("src2")).unwrap();
std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
let cands = path_candidates(dir.path(), "", true);
assert!(cands.iter().any(|c| c == "src\\" || c == "src/"));
assert!(cands.iter().any(|c| c == "src2\\" || c == "src2/"));
assert!(!cands.iter().any(|c| c.contains("readme")));
let cands = path_candidates(dir.path(), "s", true);
assert_eq!(cands.len(), 2, "{cands:?}");
// Files appear for ls completion.
let cands = path_candidates(dir.path(), "", false);
assert!(cands.iter().any(|c| c.contains("readme")));
}
#[test]
fn completes_nested_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src").join("commands")).unwrap();
std::fs::create_dir_all(dir.path().join("src").join("installers")).unwrap();
let sep = std::path::MAIN_SEPARATOR;
let prefix = format!("src{}", sep);
let cands = path_candidates(dir.path(), &format!("src{}co", sep), true);
assert!(cands.iter().any(|c| c == &format!("{prefix}commands{}", sep)));
assert!(!cands.iter().any(|c| c.contains("installers")));
}
#[test]
fn renders_ls_table() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
let table = render_ls(dir.path(), false).unwrap();
assert!(table.contains("╭"));
assert!(table.contains("╰"));
assert!(table.contains("name"));
assert!(table.contains("type"));
assert!(table.contains("modified"));
assert!(table.contains("subdir/"));
assert!(table.contains("file.txt"));
assert!(table.contains("dir"));
assert!(table.contains("file"));
assert!(table.contains("5 B"));
// Every line of the table has the same width.
let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}");
}
}
+270 -13
View File
@@ -143,6 +143,153 @@ impl Logger {
}
}
// ---------------------------------------------------------------------------
// Shared boxed outputs (the Nushell-style "white frame")
// ---------------------------------------------------------------------------
/// Truncate a string to max visible chars with an ellipsis.
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut cut: String = s.chars().take(max.saturating_sub(1)).collect();
cut.push('…');
cut
}
}
/// Build a boxed Nushell-style table (white frame, like ls) from plain rows
/// and render it, fitted to the terminal width and the active theme.
pub fn boxed_table(
headers: Vec<&str>,
rows: Vec<Vec<String>>,
theme: &crate::theme::Theme,
color: bool,
) -> String {
use crate::tables::{Cell, DataTable};
let table = DataTable {
columns: headers.into_iter().map(|h| h.to_string()).collect(),
rows: rows
.into_iter()
.map(|r| r.into_iter().map(Cell::str).collect())
.collect(),
};
table.render_themed(theme, color, terminal_width().unwrap_or(120))
}
/// Render a two-column key/value box (no header row), like the version
/// table. Keys use the theme key style, values the value style.
pub fn kv_table(
rows: &[(String, String)],
theme: &crate::theme::Theme,
color: bool,
) -> String {
let key_w = rows
.iter()
.map(|(k, _)| visible_len(k))
.max()
.unwrap_or(0);
let val_w = rows
.iter()
.map(|(_, v)| visible_len(v))
.max()
.unwrap_or(0);
let border = |left: char, mid: char, right: char| -> String {
let line = format!(
"{left}{}{mid}{}{right}",
"─".repeat(key_w + 2),
"─".repeat(val_w + 2)
);
if color {
theme.frame(&line)
} else {
line
}
};
let paint = |s: &str, color: bool, style: fn(&crate::theme::Theme, &str) -> String| {
if color && !s.contains('\u{1b}') {
style(theme, s)
} else {
s.to_string()
}
};
let bar = if color {
theme.frame("│")
} else {
"│".to_string()
};
let mut out = String::new();
out.push_str(&border('╭', '┬', '╮'));
out.push('\n');
for (k, v) in rows {
let key = paint(k, color, crate::theme::Theme::key);
let val = paint(v, color, crate::theme::Theme::val);
out.push_str(&format!(
"{bar} {} {bar} {} {bar}\n",
pad(key.as_str(), key_w),
pad(val.as_str(), val_w)
));
}
out.push_str(&border('╰', '┴', '╯'));
out.push('\n');
out
}
/// Build the lines of the boxed Tab-completion menu from (candidate,
/// description) rows. Single column when no descriptions exist.
pub fn completion_box(rows: &[(String, String)]) -> Vec<String> {
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
let name_w = rows
.iter()
.map(|(n, _)| n.chars().count())
.max()
.unwrap_or(0)
.max("name".chars().count());
let desc_w = if has_desc {
rows.iter()
.map(|(_, d)| truncate(d, 40).chars().count())
.max()
.unwrap_or(0)
.max("description".chars().count())
} else {
0
};
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
line.push_str(&"─".repeat(name_w + 2));
if has_desc {
line.push(mid);
line.push_str(&"─".repeat(desc_w + 2));
}
line.push(right);
line
};
let mut out = Vec::new();
out.push(border('╭', '┬', '╮'));
if has_desc {
out.push(format!(
"│ {} │ {} │",
pad("name", name_w),
pad("description", desc_w)
));
out.push(border('├', '┼', '┤'));
}
for (name, desc) in rows {
if has_desc {
out.push(format!(
"│ {} │ {} │",
pad(name, name_w),
pad(&truncate(desc, 40), desc_w)
));
} else {
out.push(format!("│ {} │", pad(name, name_w)));
}
}
out.push(border('╰', '┴', '╯'));
out
}
// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------
@@ -213,22 +360,21 @@ impl Table {
}
}
/// Color a status cell for display in tables.
pub fn status_style(status: &str, color: bool) -> String {
let s = status.to_string();
/// Color a status cell for display in tables (theme-aware).
pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String {
if !color {
return s;
return status.to_string();
}
match status {
"running" => s.green().bold().to_string(),
"installed" => s.cyan().to_string(),
"external" => s.yellow().to_string(),
"not-installable" => s.red().to_string(),
"not-installed" => s.dimmed().to_string(),
"ok" => s.green().to_string(),
"warn" => s.yellow().to_string(),
"error" => s.red().bold().to_string(),
_ => s,
"running" => theme.acc(status),
"installed" => theme.inf(status),
"external" => theme.warn(status),
"not-installable" => theme.err(status),
"not-installed" => theme.dimmed(status),
"ok" => theme.ok(status),
"warn" => theme.warn(status),
"error" => theme.err(status),
_ => status.to_string(),
}
}
@@ -239,6 +385,42 @@ pub fn print_json<T: Serialize>(value: &T) {
}
}
/// Width of the terminal attached to stdout, in columns. Returns None when
/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly
/// for tests and scripts.
pub fn terminal_width() -> Option<usize> {
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
if let Ok(w) = v.trim().parse::<usize>() {
return Some(w);
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO,
};
let handle = std::io::stdout().as_raw_handle() as isize;
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 {
let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize;
if width > 0 {
return Some(width);
}
}
None
}
#[cfg(not(windows))]
{
if let Ok(v) = std::env::var("COLUMNS") {
if let Ok(w) = v.trim().parse::<usize>() {
return Some(w);
}
}
None
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
@@ -271,3 +453,78 @@ fn pad(s: &str, width: usize) -> String {
format!("{s}{}", " ".repeat(width - n))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_box_with_descriptions_is_aligned() {
let rows = vec![
("list".to_string(), "list installed agents".to_string()),
("status".to_string(), "show agent state".to_string()),
];
let lines = completion_box(&rows);
assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom
assert!(lines[0].starts_with('╭') && lines[0].contains('┬'));
assert!(lines[0].ends_with('╮'));
assert!(lines[1].contains("name") && lines[1].contains("description"));
assert!(lines[2].starts_with('├'));
assert!(lines[5].starts_with('╰'));
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
assert!(
widths.iter().all(|w| *w == widths[0]),
"unaligned: {lines:?}"
);
assert!(lines[3].contains("list") && lines[3].contains("list installed agents"));
}
#[test]
fn completion_box_without_descriptions_is_single_column() {
let rows = vec![
("src".to_string(), "".to_string()),
("am.exe".to_string(), "".to_string()),
];
let lines = completion_box(&rows);
assert_eq!(lines.len(), 4); // top, 2 rows, bottom
assert!(!lines.iter().any(|l| l.contains("description")));
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]));
}
#[test]
fn kv_table_is_aligned_with_and_without_colors() {
let rows = vec![
("version".to_string(), "0.2.13".to_string()),
("build_os".to_string(), "windows-x86_64".to_string()),
];
for color in [false, true] {
let text = kv_table(&rows, crate::theme::default_theme(), color);
// Compare visible widths (ANSI codes must not shift columns).
let widths: Vec<usize> = text
.lines()
.map(|l| strip_ansi(l).chars().count())
.collect();
assert!(
widths.iter().all(|w| *w == widths[0]),
"unaligned ({color}):\n{text}"
);
}
// Color output really contains ANSI sequences.
let colored = kv_table(&rows, crate::theme::default_theme(), true);
assert!(colored.contains("\x1b["));
}
#[test]
fn boxed_table_has_a_frame() {
let text = boxed_table(
vec!["NAME", "STATUS"],
vec![vec!["claude-code".to_string(), "external".to_string()]],
crate::theme::default_theme(),
false,
);
assert!(text.contains("╭") && text.contains("╰"));
assert!(text.contains("claude-code"));
assert!(text.contains("NAME"));
}
}
+218
View File
@@ -0,0 +1,218 @@
//! ps: Nushell-style process table.
//!
//! Windows uses the Toolhelp32 snapshot API (pid, ppid, name, threads, cpu
//! time, working set); Unix reads /proc.
use crate::tables::{Cell, DataTable};
/// Columns: pid, ppid, name, cpu, mem, threads.
fn columns() -> Vec<String> {
vec![
"pid".into(),
"ppid".into(),
"name".into(),
"cpu".into(),
"mem".into(),
"threads".into(),
]
}
/// Build the process table.
pub fn ps_table() -> DataTable {
#[cfg(windows)]
let rows = windows_rows();
#[cfg(unix)]
let rows = unix_rows();
#[cfg(not(any(windows, unix)))]
let rows = Vec::new();
let mut rows = rows;
rows.sort_by(|a, b| {
a[0]
.num
.unwrap_or(0.0)
.partial_cmp(&b[0].num.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
DataTable {
columns: columns(),
rows,
}
}
// ---------------------------------------------------------------------------
// Windows: Toolhelp32 snapshot
// ---------------------------------------------------------------------------
#[cfg(windows)]
fn windows_rows() -> Vec<Vec<Cell>> {
use crate::nav::format_size;
use std::mem::size_of;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::ProcessStatus::{
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION,
};
let mut rows: Vec<Vec<Cell>> = Vec::new();
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return rows;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = size_of::<PROCESSENTRY32W>() as u32;
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 {
let pid = entry.th32ProcessID;
let ppid = entry.th32ParentProcessID;
let threads = entry.cntThreads;
let mut name = String::new();
for &unit in entry.szExeFile.iter() {
if unit == 0 {
break;
}
if let Some(c) = char::from_u32(unit as u32) {
name.push(c);
}
}
// CPU time and memory need a process handle; some system processes
// refuse — those cells stay "—".
let mut cpu = "—".to_string();
let mut mem = "—".to_string();
let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) };
if handle != 0 {
let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
if unsafe {
K32GetProcessMemoryInfo(
handle,
&mut counters,
size_of::<PROCESS_MEMORY_COUNTERS>() as u32,
)
} != 0
{
mem = format_size(counters.WorkingSetSize as u64);
}
let (mut created, mut exited, mut kernel, mut user): (
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
) = unsafe {
(
std::mem::zeroed(),
std::mem::zeroed(),
std::mem::zeroed(),
std::mem::zeroed(),
)
};
if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } != 0 {
let ticks = (filetime_u64(&kernel) + filetime_u64(&user)) as f64 / 10_000_000.0;
cpu = format!("{ticks:.0}s");
}
unsafe { CloseHandle(handle) };
}
rows.push(vec![
Cell::num(pid as f64, pid.to_string()),
Cell::num(ppid as f64, ppid.to_string()),
Cell::str(name),
Cell::str(cpu),
Cell::str(mem),
Cell::num(threads as f64, threads.to_string()),
]);
ok = unsafe { Process32NextW(snapshot, &mut entry) };
}
unsafe { CloseHandle(snapshot) };
rows
}
#[cfg(windows)]
fn filetime_u64(ft: &windows_sys::Win32::Foundation::FILETIME) -> u64 {
((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
}
// ---------------------------------------------------------------------------
// Unix: /proc
// ---------------------------------------------------------------------------
#[cfg(unix)]
fn unix_rows() -> Vec<Vec<Cell>> {
use crate::nav::format_size;
let page_size = page_size();
let mut rows: Vec<Vec<Cell>> = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return rows;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Ok(pid) = name.parse::<f64>() else {
continue;
};
let base = entry.path();
let Ok(stat) = std::fs::read_to_string(base.join("stat")) else {
continue;
};
// The comm field is in parentheses and may contain spaces.
let Some(close) = stat.rfind(')') else {
continue;
};
let comm = stat[stat.find('(').unwrap_or(0) + 1..close].to_string();
let rest: Vec<&str> = stat[close + 2..].split_whitespace().collect();
if rest.len() < 22 {
continue;
}
// rest[0]=state, rest[1]=ppid, rest[11]=utime, rest[12]=stime,
// rest[17]=num_threads, rest[21]=rss (pages).
let ppid = rest[1].parse::<f64>().unwrap_or(0.0);
let utime = rest[11].parse::<f64>().unwrap_or(0.0);
let stime = rest[12].parse::<f64>().unwrap_or(0.0);
let threads = rest[17].parse::<f64>().unwrap_or(0.0);
let rss_pages = rest[21].parse::<u64>().unwrap_or(0);
let cpu = (utime + stime) / 100.0;
rows.push(vec![
Cell::num(pid, pid.to_string()),
Cell::num(ppid, ppid.to_string()),
Cell::str(comm),
Cell::str(format!("{cpu:.0}s")),
Cell::str(format_size(rss_pages * page_size)),
Cell::num(threads, threads.to_string()),
]);
}
rows
}
#[cfg(unix)]
fn page_size() -> u64 {
// Standard page size; RSS in pages * 4096 is close enough for display.
4096
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ps_produces_a_table() {
let table = ps_table();
assert_eq!(
table.columns,
vec!["pid", "ppid", "name", "cpu", "mem", "threads"]
);
assert!(!table.rows.is_empty(), "process table should not be empty");
// Our own pid is in the table.
let me = std::process::id().to_string();
assert!(
table.rows.iter().any(|r| r[0].text == me),
"our pid should be listed"
);
// Rows have as many cells as columns.
assert!(table.rows.iter().all(|r| r.len() == 6));
}
}
+1356 -67
View File
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
//! System-shell gateway.
//!
//! The interactive prompt doubles as a gateway between the user and their
//! system shell: every input that is not an am command is executed by the
//! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...).
//!
//! The default shell is resolved at startup from, in order: the
//! `settings.default_shell` configuration entry, the `SHELL` environment
//! variable, `COMSPEC` (Windows), then the first shell found on PATH among
//! the platform's preferred shells. `shell <name>` switches at runtime.
use crate::config::Config;
use anyhow::{anyhow, Result};
use std::ffi::OsString;
use std::path::Path;
use std::process::Command as ProcessCommand;
/// One supported system shell and how to invoke it with a command line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellSpec {
/// Canonical short name ("pwsh", "cmd", "bash", ...).
pub name: &'static str,
/// Human-readable title ("PowerShell 7", "Command Prompt", ...).
pub title: &'static str,
/// Executable to spawn.
pub program: &'static str,
/// Arguments placed before the user's command line.
pub args: &'static [&'static str],
/// Executable probed on PATH to decide whether the shell is available.
pub probe: &'static str,
}
/// Every shell the gateway understands, in display order.
pub static KNOWN_SHELLS: &[ShellSpec] = &[
ShellSpec {
name: "pwsh",
title: "PowerShell 7",
program: "pwsh",
args: &["-NoLogo", "-NoProfile", "-Command"],
probe: "pwsh",
},
ShellSpec {
name: "powershell",
title: "Windows PowerShell",
program: "powershell",
args: &["-NoLogo", "-NoProfile", "-Command"],
probe: "powershell",
},
ShellSpec {
name: "cmd",
title: "Command Prompt",
program: "cmd",
args: &["/C"],
probe: "cmd",
},
ShellSpec {
name: "bash",
title: "Bash",
program: "bash",
args: &["-c"],
probe: "bash",
},
ShellSpec {
name: "zsh",
title: "Zsh",
program: "zsh",
args: &["-c"],
probe: "zsh",
},
ShellSpec {
name: "fish",
title: "Fish",
program: "fish",
args: &["-c"],
probe: "fish",
},
ShellSpec {
name: "sh",
title: "POSIX sh",
program: "sh",
args: &["-c"],
probe: "sh",
},
ShellSpec {
name: "nu",
title: "Nushell",
program: "nu",
args: &["-c"],
probe: "nu",
},
ShellSpec {
name: "elvish",
title: "Elvish",
program: "elvish",
args: &["-c"],
probe: "elvish",
},
];
/// Resolve a shell by canonical name or common alias, case-insensitively
/// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...).
pub fn find(name: &str) -> Option<&'static ShellSpec> {
let mut n = name.trim().to_ascii_lowercase();
if let Some(stripped) = n.strip_suffix(".exe") {
n = stripped.to_string();
}
KNOWN_SHELLS.iter().find(|s| {
s.name == n
|| match s.name {
"pwsh" => n == "powershell7" || n == "ps7",
"powershell" => n == "ps",
"cmd" => n == "dos" || n == "command" || n == "command-prompt",
"sh" => n == "posix" || n == "dash",
"nu" => n == "nushell",
_ => false,
}
})
}
/// Whether a shell executable can be found (or, for cmd on Windows, whether
/// COMSPEC is set).
pub fn probe_available(spec: &ShellSpec) -> bool {
which::which(spec.probe).is_ok()
|| (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some())
}
/// Resolve the user's default shell from the configuration, environment
/// variables, then the shells found on PATH. Always returns a shell.
pub fn resolve_default(
configured: Option<&str>,
shell_env: Option<OsString>,
comspec: Option<OsString>,
) -> &'static ShellSpec {
if let Some(s) = configured.and_then(find) {
return s;
}
if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) {
return s;
}
if let Some(s) = comspec.and_then(|v| from_env_path(&v)) {
return s;
}
let preferred: &[&str] = if cfg!(windows) {
&["pwsh", "powershell", "cmd"]
} else {
&["bash", "zsh", "fish", "sh"]
};
for name in preferred {
if let Some(s) = find(name) {
if probe_available(s) {
return s;
}
}
}
// Last resort: the platform's built-in shell.
find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist")
}
/// Extract a shell name from an environment value such as SHELL or COMSPEC.
fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> {
let text = value.to_string_lossy();
let name = Path::new(text.as_ref())
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
find(&name)
}
/// The gateway's runtime state: which shell executes system commands now,
/// which one was detected as the user's default, and which are installed.
#[derive(Debug, Clone)]
pub struct ShellSession {
/// Shell executing system commands right now.
pub current: &'static ShellSpec,
/// The shell detected as the user's default at startup.
pub default: &'static ShellSpec,
/// Shells found on this machine.
pub available: Vec<&'static ShellSpec>,
}
impl ShellSession {
/// Build a session: detect the default shell and probe availability.
pub fn start(config: &Config) -> Self {
let default = resolve_default(
config.settings.default_shell.as_deref(),
std::env::var_os("SHELL"),
std::env::var_os("COMSPEC"),
);
let available = KNOWN_SHELLS
.iter()
.filter(|s| probe_available(s))
.collect();
Self {
current: default,
default,
available,
}
}
/// Switch the active shell. Fails for unknown or unavailable shells.
pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> {
let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?;
if !self.available.iter().any(|s| s.name == spec.name) {
return Err(anyhow!(
"shell '{}' is not available on this machine (available: {})",
spec.name,
self.available
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join(", ")
));
}
self.current = spec;
Ok(spec)
}
/// Whether the active shell is the user's default shell.
pub fn is_default(&self) -> bool {
self.current.name == self.default.name
}
/// Execute one system command line in the current shell with inherited
/// stdio, so interactive programs keep working. Returns the exit code.
pub fn run(&self, line: &str) -> Result<i32> {
let status = ProcessCommand::new(self.current.program)
.args(self.current.args)
.arg(line)
.status()
.map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?;
Ok(status.code().unwrap_or(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_resolves_aliases() {
assert_eq!(find("pwsh").unwrap().name, "pwsh");
assert_eq!(find("PowerShell7").unwrap().name, "pwsh");
assert_eq!(find("ps").unwrap().name, "powershell");
assert_eq!(find("cmd.exe").unwrap().name, "cmd");
assert_eq!(find("CMD").unwrap().name, "cmd");
assert_eq!(find("nushell").unwrap().name, "nu");
assert_eq!(find("dash").unwrap().name, "sh");
assert!(find("blorp").is_none());
}
#[test]
fn resolve_default_prefers_configured_shell() {
let s = resolve_default(Some("zsh"), None, None);
assert_eq!(s.name, "zsh");
}
#[test]
fn resolve_default_uses_environment_values() {
// SHELL wins over COMSPEC when the configured value is absent.
let s = resolve_default(
None,
Some(OsString::from("/usr/bin/bash")),
Some(OsString::from("C:/Windows/system32/cmd.exe")),
);
assert_eq!(s.name, "bash");
// COMSPEC alone is honored.
let s = resolve_default(
None,
None,
Some(OsString::from("C:/Windows/system32/cmd.exe")),
);
assert_eq!(s.name, "cmd");
// Unrecognized values fall through to a probe — result is still a
// known shell.
let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None);
assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name));
}
#[test]
fn switch_validates_availability() {
let sh = find("sh").unwrap();
let bash = find("bash").unwrap();
let mut session = ShellSession {
current: sh,
default: sh,
available: vec![sh, bash],
};
assert_eq!(session.switch("bash").unwrap().name, "bash");
assert_eq!(session.current.name, "bash");
assert!(!session.is_default());
let err = session.switch("zsh").unwrap_err();
assert!(err.to_string().contains("not available"), "{err}");
let err = session.switch("blorp").unwrap_err();
assert!(err.to_string().contains("unknown shell"), "{err}");
}
}
+459
View File
@@ -0,0 +1,459 @@
//! Generic Nushell-style tables: render a boxed table, filter rows with
//! where-like predicates and select a column with get.
use anyhow::{bail, Result};
use colored::Colorize;
/// One cell: the text to display plus a typed value for filtering
/// (number, byte size, or plain string).
#[derive(Debug, Clone)]
pub struct Cell {
pub text: String,
pub num: Option<f64>,
pub bytes: Option<u64>,
}
impl Cell {
pub fn str(s: impl Into<String>) -> Self {
Cell {
text: s.into(),
num: None,
bytes: None,
}
}
pub fn num(n: f64, text: String) -> Self {
Cell {
text,
num: Some(n),
bytes: None,
}
}
pub fn bytes(b: u64, text: String) -> Self {
Cell {
text,
num: None,
bytes: Some(b),
}
}
}
/// A tabular result produced by ls, ps, where or get.
#[derive(Debug, Clone, Default)]
pub struct DataTable {
pub columns: Vec<String>,
pub rows: Vec<Vec<Cell>>,
}
impl DataTable {
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
}
/// Render the boxed Nushell-style table, fitting max_width columns.
/// When the natural layout is too wide, the first (name) column is
/// truncated with an ellipsis and the trailing columns are dropped if
/// that is still not enough — every row stays on a single line.
pub fn render(&self, color: bool, max_width: usize) -> String {
self.render_with(None, color, max_width)
}
/// Render with the colors of a theme (borders dimmed, header row in the
/// theme's header style instead of plain bold).
pub fn render_themed(
&self,
theme: &crate::theme::Theme,
color: bool,
max_width: usize,
) -> String {
self.render_with(Some(theme), color, max_width)
}
fn render_with(
&self,
theme: Option<&crate::theme::Theme>,
color: bool,
max_width: usize,
) -> String {
let max_width = max_width.max(20);
let headers = self.columns.clone();
if headers.is_empty() {
return String::new();
}
// Column 0 is the row index; the data columns follow.
let index_width = self.rows.len().to_string().len().max(1);
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
let mut cells: Vec<Vec<String>> = Vec::new();
for (i, row) in self.rows.iter().enumerate() {
let mut line = vec![i.to_string()];
for cell in row {
line.push(cell.text.clone());
}
for (c, cell) in line.iter().enumerate().skip(1) {
natural[c - 1] = natural[c - 1].max(visible_len(cell));
}
cells.push(line);
}
let line_width = |ncols: usize, widths: &[usize]| -> usize {
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
};
// Choose the final column set and widths.
let mut data_cols = headers.len();
let mut widths: Vec<usize> = natural.clone();
loop {
if data_cols == 0 {
break;
}
widths.truncate(data_cols);
if line_width(data_cols, &widths) <= max_width {
// Expand the first column into the remaining slack.
widths[0] += max_width - line_width(data_cols, &widths);
break;
}
// Cap the non-first columns, then give the first what remains.
let mut capped = widths.clone();
for w in capped.iter_mut().skip(1) {
*w = (*w).min(16);
}
if line_width(data_cols, &capped) <= max_width {
capped[0] += max_width - line_width(data_cols, &capped);
widths = capped;
break;
}
if data_cols == 1 {
// Last resort: shrink the single column to whatever remains
// after the borders and the index column.
let fixed = 1 + 3 * (data_cols + 1) + index_width;
capped[0] = max_width.saturating_sub(fixed).max(1);
widths = capped;
break;
}
data_cols -= 1;
}
let all_headers: Vec<String> = std::iter::once("#".to_string())
.chain(headers.iter().take(data_cols).cloned())
.collect();
let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned());
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
for (i, w) in all_widths.iter().enumerate() {
if i > 0 {
line.push(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push(right);
line
};
let bar = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.to_string(),
}
};
let row_line = |row: &[String], header: bool| -> String {
let mut line = bar("│");
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
line.push(' ');
let cell = fit_cell(c, all_widths[i]);
let rendered = if header {
match theme {
Some(t) if color => t.hdr(&cell),
_ => cell.bold().to_string(),
}
} else {
cell
};
line.push_str(&rendered);
line.push(' ');
line.push_str(&bar("│"));
}
line
};
let edge = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.dimmed().to_string(),
}
};
let mut out = String::new();
out.push_str(&edge(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row_line(&all_headers, true));
out.push('\n');
out.push_str(&edge(&border('├', '┼', '┤')));
out.push('\n');
for line in &cells {
out.push_str(&row_line(line, false));
out.push('\n');
}
out.push_str(&edge(&border('╰', '┴', '╯')));
out.push('\n');
out
}
}
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
/// become numbers, everything else is a string.
#[derive(Debug, Clone, PartialEq)]
enum WhereValue {
Num(f64),
Bytes(u64),
Str(String),
}
fn parse_value(raw: &str) -> WhereValue {
let lower = raw.trim().to_lowercase();
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
if let Some(n) = lower.strip_suffix(suffix) {
if let Ok(v) = n.trim().parse::<f64>() {
return WhereValue::Bytes((v * mult as f64) as u64);
}
}
}
if let Ok(v) = raw.trim().parse::<f64>() {
return WhereValue::Num(v);
}
WhereValue::Str(raw.trim().to_string())
}
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
match op {
">" => a > b,
"<" => a < b,
">=" => a >= b,
"<=" => a <= b,
"==" => (a - b).abs() < f64::EPSILON,
"!=" => (a - b).abs() >= f64::EPSILON,
_ => false,
}
}
/// Filter the rows of a table: where <field> <op> <value>.
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
let val = parse_value(value);
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
if !supported {
bail!("unknown operator '{op}' — use > < >= <= == != =~");
}
let mut out = table.clone();
out.rows.retain(|row| match row.get(idx) {
None => false,
Some(cell) => match &val {
WhereValue::Num(n) => match cell.num {
Some(v) => num_cmp(v, *n, op),
None => false,
},
WhereValue::Bytes(b) => match cell.bytes {
Some(v) => num_cmp(v as f64, *b as f64, op),
None => false,
},
WhereValue::Str(s) => {
let text = strip_ansi(&cell.text);
match op {
"==" => text.eq_ignore_ascii_case(s),
"!=" => !text.eq_ignore_ascii_case(s),
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
_ => false,
}
}
},
});
Ok(out)
}
/// Select one column: get <column>.
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
Ok(DataTable {
columns: vec![field.to_string()],
rows: table
.rows
.iter()
.map(|row| vec![row[idx].clone()])
.collect(),
})
}
/// Pad a cell to its column width, or truncate it (with an ellipsis,
/// preserving any leading color codes) when it is too wide.
fn fit_cell(s: &str, w: usize) -> String {
let n = visible_len(s);
if n <= w {
return format!("{s}{}", " ".repeat(w - n));
}
let plain = strip_ansi(s);
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
cut.push('…');
if s.contains('') {
let mut prefix = String::new();
let mut chars = s.chars().peekable();
while chars.peek() == Some(&'') {
for ch in chars.by_ref() {
prefix.push(ch);
if ch == 'm' {
break;
}
}
}
format!("{prefix}{cut}")
} else {
cut
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> DataTable {
DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![
vec![
Cell::str("src"),
Cell::str("dir"),
Cell::bytes(2048, "2.0 KB".into()),
],
vec![
Cell::str("am.exe"),
Cell::str("file"),
Cell::bytes(5_000_000, "4.8 MB".into()),
],
vec![
Cell::str("readme.md"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
],
],
}
}
#[test]
fn filters_numeric_and_size() {
let t = sample();
let big = filter_where(&t, "size", ">", "1mb").unwrap();
assert_eq!(big.rows.len(), 1);
assert_eq!(big.rows[0][0].text, "am.exe");
let eq = filter_where(&t, "type", "==", "dir").unwrap();
assert_eq!(eq.rows.len(), 1);
let contains = filter_where(&t, "name", "=~", "read").unwrap();
assert_eq!(contains.rows.len(), 1);
}
#[test]
fn get_selects_column() {
let t = sample();
let names = get_column(&t, "name").unwrap();
assert_eq!(names.columns, vec!["name".to_string()]);
assert_eq!(names.rows.len(), 3);
assert_eq!(names.rows[1][0].text, "am.exe");
}
#[test]
fn unknown_column_errors() {
let t = sample();
assert!(filter_where(&t, "nope", "==", "x").is_err());
assert!(get_column(&t, "nope").is_err());
}
#[test]
fn parses_where_values() {
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
}
#[test]
fn render_is_aligned() {
let t = sample();
let text = t.render(false, 200);
assert!(text.contains("╭"));
assert!(text.contains("name"));
assert!(text.contains("am.exe"));
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
}
#[test]
fn render_fits_narrow_width() {
// A table whose first column is too long to fit naturally.
let t = DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![vec![
Cell::str("a-very-long-file-name-that-overflows.txt"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
]],
};
// Trailing columns are dropped when too narrow.
let text = t.render(false, 45);
for line in text.lines() {
assert!(line.chars().count() <= 45, "too wide: {line}");
}
assert!(!text.contains("size"));
// The name column is truncated with an ellipsis at the very end.
let text = t.render(false, 30);
for line in text.lines() {
assert!(line.chars().count() <= 30, "too wide: {line}");
}
assert!(text.contains("…"), "name should be truncated:
{text}");
}
#[test]
fn fit_cell_truncates_and_keeps_color() {
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
let out = fit_cell(&colored, 8);
assert_eq!(strip_ansi(&out).chars().count(), 8);
assert!(out.starts_with("\u{1b}["));
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Output color themes: named palettes applied to help, tables, the
//! version output and the completion menu.
//!
//! A theme is a set of SGR (Select Graphic Rendition) parameter strings
//! (e.g. "1;38;5;75" = bold + 256-color 75). Painting wraps the text in
//! "\x1b[<sgr>m...\x1b[0m". The "mono" theme uses empty sequences and is
//! therefore plain text.
/// One color theme.
#[derive(Debug, Clone, Copy)]
pub struct Theme {
/// Machine name ("default", "ocean", ...).
pub name: &'static str,
/// Human-readable title.
pub title: &'static str,
/// Section headers ("Usage:", table header rows, ...) — bold.
pub header: &'static str,
/// Accent color: prompts, command names, status "running".
pub accent: &'static str,
/// Secondary color: types, values, status "installed".
pub info: &'static str,
/// Faint: search terms, secondary text, "not-installed".
pub dim: &'static str,
/// Frame color: the borders of tables and boxes (list, version, ls...).
pub border: &'static str,
/// Warnings and "external" status.
pub warn: &'static str,
/// Errors and "not-installable".
pub error: &'static str,
/// Success / "ok".
pub ok: &'static str,
/// Version table keys.
pub key: &'static str,
/// Version table values.
pub value: &'static str,
}
pub static THEMES: &[Theme] = &[
Theme {
name: "default",
title: "Default (green/cyan)",
header: "1;36",
accent: "1;32",
info: "36",
dim: "2",
border: "36",
warn: "33",
error: "31",
ok: "1;32",
key: "36",
value: "1",
},
Theme {
name: "ocean",
title: "Ocean (blues)",
header: "1;38;5;75",
accent: "1;38;5;81",
info: "38;5;117",
dim: "2",
border: "38;5;75",
warn: "38;5;221",
error: "38;5;203",
ok: "1;38;5;81",
key: "38;5;111",
value: "1",
},
Theme {
name: "sunset",
title: "Sunset (orange/pink)",
header: "1;38;5;209",
accent: "1;38;5;215",
info: "38;5;213",
dim: "2",
border: "38;5;209",
warn: "38;5;221",
error: "38;5;203",
ok: "1;38;5;215",
key: "38;5;213",
value: "1",
},
Theme {
name: "forest",
title: "Forest (greens)",
header: "1;38;5;71",
accent: "1;38;5;107",
info: "38;5;72",
dim: "2",
border: "38;5;71",
warn: "38;5;179",
error: "38;5;167",
ok: "1;38;5;107",
key: "38;5;108",
value: "1",
},
Theme {
name: "dracula",
title: "Dracula (purple/green)",
header: "1;38;5;141",
accent: "1;38;5;84",
info: "38;5;117",
dim: "2",
border: "38;5;141",
warn: "38;5;228",
error: "38;5;203",
ok: "1;38;5;84",
key: "38;5;141",
value: "1",
},
Theme {
name: "nord",
title: "Nord (arctic frost)",
header: "1;38;5;109",
accent: "1;38;5;108",
info: "38;5;110",
dim: "2",
border: "38;5;109",
warn: "38;5;222",
error: "38;5;167",
ok: "1;38;5;108",
key: "38;5;110",
value: "1",
},
Theme {
name: "solarized",
title: "Solarized (dark)",
header: "1;38;5;32",
accent: "1;38;5;100",
info: "38;5;37",
dim: "2",
border: "38;5;102",
warn: "38;5;136",
error: "38;5;160",
ok: "1;38;5;100",
key: "38;5;37",
value: "1",
},
Theme {
name: "gruvbox",
title: "Gruvbox (retro warm)",
header: "1;38;5;109",
accent: "1;38;5;142",
info: "38;5;66",
dim: "2",
border: "38;5;59",
warn: "38;5;214",
error: "38;5;167",
ok: "1;38;5;142",
key: "38;5;108",
value: "1",
},
Theme {
name: "catppuccin",
title: "Catppuccin (pastel)",
header: "1;38;5;111",
accent: "1;38;5;114",
info: "38;5;117",
dim: "2",
border: "38;5;111",
warn: "38;5;229",
error: "38;5;210",
ok: "1;38;5;114",
key: "38;5;140",
value: "1",
},
Theme {
name: "tokyo-night",
title: "Tokyo Night (neon)",
header: "1;38;5;111",
accent: "1;38;5;114",
info: "38;5;117",
dim: "2",
border: "38;5;111",
warn: "38;5;215",
error: "38;5;204",
ok: "1;38;5;114",
key: "38;5;141",
value: "1",
},
Theme {
name: "monokai",
title: "Monokai (classic IDE)",
header: "1;38;5;81",
accent: "1;38;5;148",
info: "38;5;81",
dim: "2",
border: "38;5;81",
warn: "38;5;185",
error: "38;5;197",
ok: "1;38;5;148",
key: "38;5;141",
value: "1",
},
Theme {
name: "matrix",
title: "Matrix (green phosphor)",
header: "1;38;5;46",
accent: "1;38;5;40",
info: "38;5;34",
dim: "2",
border: "38;5;34",
warn: "38;5;190",
error: "38;5;196",
ok: "1;38;5;40",
key: "38;5;40",
value: "1",
},
Theme {
name: "rose-pine",
title: "Rose Pine (elegant pink)",
header: "1;38;5;183",
accent: "1;38;5;216",
info: "38;5;117",
dim: "2",
border: "38;5;183",
warn: "38;5;180",
error: "38;5;204",
ok: "1;38;5;216",
key: "38;5;183",
value: "1",
},
Theme {
name: "amber",
title: "Amber (retro CRT)",
header: "1;38;5;214",
accent: "1;38;5;215",
info: "38;5;178",
dim: "2",
border: "38;5;172",
warn: "38;5;220",
error: "38;5;196",
ok: "1;38;5;215",
key: "38;5;214",
value: "1",
},
Theme {
name: "mono",
title: "Mono (no colors)",
header: "1",
accent: "",
info: "",
dim: "",
border: "",
warn: "",
error: "",
ok: "1",
key: "",
value: "",
},
];
/// The theme used when nothing else is configured.
pub fn default_theme() -> &'static Theme {
&THEMES[0]
}
/// Look a theme up by name (case-insensitive).
pub fn find(name: &str) -> Option<&'static Theme> {
let lower = name.trim().to_lowercase();
THEMES.iter().find(|t| t.name.to_lowercase() == lower)
}
/// Machine names of every theme, for error messages and help.
pub fn names() -> Vec<&'static str> {
THEMES.iter().map(|t| t.name).collect()
}
/// One line per theme: "name - title", for the help output.
pub fn describe_all() -> Vec<String> {
THEMES
.iter()
.map(|t| format!("{} - {}", t.name, t.title))
.collect()
}
impl Theme {
/// Wrap text in this SGR sequence (empty sequence = plain text).
pub fn paint(&self, sgr: &str, text: &str) -> String {
if sgr.is_empty() || text.is_empty() {
text.to_string()
} else {
format!("\x1b[{sgr}m{text}\x1b[0m")
}
}
pub fn hdr(&self, text: &str) -> String {
self.paint(self.header, text)
}
pub fn acc(&self, text: &str) -> String {
self.paint(self.accent, text)
}
pub fn inf(&self, text: &str) -> String {
self.paint(self.info, text)
}
pub fn dimmed(&self, text: &str) -> String {
self.paint(self.dim, text)
}
pub fn frame(&self, text: &str) -> String {
self.paint(self.border, text)
}
pub fn warn(&self, text: &str) -> String {
self.paint(self.warn, text)
}
pub fn err(&self, text: &str) -> String {
self.paint(self.error, text)
}
pub fn ok(&self, text: &str) -> String {
self.paint(self.ok, text)
}
pub fn key(&self, text: &str) -> String {
self.paint(self.key, text)
}
pub fn val(&self, text: &str) -> String {
self.paint(self.value, text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_themes_case_insensitively() {
assert_eq!(find("Ocean").unwrap().name, "ocean");
assert_eq!(find("default").unwrap().name, "default");
assert_eq!(find("DRACULA").unwrap().name, "dracula");
assert!(find("blorp").is_none());
}
#[test]
fn default_is_first() {
assert_eq!(default_theme().name, "default");
}
#[test]
fn mono_theme_paints_nothing() {
let mono = find("mono").unwrap();
// Bold (structure) survives; colors are gone.
assert_eq!(mono.hdr("x"), "x");
assert_eq!(mono.acc("x"), "x");
assert_eq!(mono.inf("x"), "x");
assert_eq!(mono.dimmed("x"), "x");
assert_eq!(mono.key("x"), "x");
assert_eq!(mono.val("x"), "x");
}
#[test]
fn paint_wraps_with_ansi() {
let t = default_theme();
let s = t.acc("ok");
assert!(s.starts_with("\x1b[1;32m"));
assert!(s.ends_with("\x1b[0m"));
}
#[test]
fn every_theme_has_a_description() {
for t in THEMES {
assert!(!t.title.is_empty(), "{}: missing title", t.name);
}
}
#[test]
fn theme_names_are_unique() {
let mut names: Vec<&str> = THEMES.iter().map(|t| t.name).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), THEMES.len(), "duplicate theme names");
}
#[test]
fn extended_catalog_is_findable() {
for name in [
"nord",
"solarized",
"gruvbox",
"catppuccin",
"tokyo-night",
"monokai",
"matrix",
"rose-pine",
"amber",
] {
assert!(find(name).is_some(), "missing theme {name}");
}
assert_eq!(THEMES.len(), 15);
}
#[test]
fn frame_paints_with_the_theme_border_color() {
let t = find("ocean").unwrap();
let s = t.frame("╭──╮");
assert!(s.starts_with(""));
assert_eq!(find("mono").unwrap().frame("x"), "x");
}
}
+95 -1
View File
@@ -5,6 +5,10 @@
//! already present (scoop/winget on Windows, apt/dnf/pacman/apk on Linux,
//! brew on macOS) — and proposes the exact command to install it.
use crate::runner::Runner;
use anyhow::Result;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
Windows,
@@ -130,6 +134,66 @@ pub fn install_commands(tool: &str, os: &OsInfo) -> Vec<String> {
out
}
/// Run an installer command line. Commands containing shell syntax
/// (pipes, redirections, ...) are executed through the shell; plain
/// commands are spawned directly.
pub fn run_install_command(runner: &dyn Runner, cmd: &str) -> Result<i32> {
let needs_shell = cmd.contains('|')
|| cmd.contains('>')
|| cmd.contains('<')
|| cmd.contains('&')
|| cmd.contains(';');
let (prog, args) = if needs_shell {
#[cfg(windows)]
{
("cmd".to_string(), vec!["/C".to_string(), cmd.to_string()])
}
#[cfg(not(windows))]
{
("sh".to_string(), vec!["-c".to_string(), cmd.to_string()])
}
} else {
let tokens = shell_words::split(cmd)
.unwrap_or_else(|_| cmd.split_whitespace().map(String::from).collect());
if tokens.is_empty() {
return Ok(0);
}
(tokens[0].clone(), tokens[1..].to_vec())
};
let empty = std::collections::BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
Ok(out.code)
}
/// After installers that modify ~/.profile or ~/.zshrc (rustup, bun, uv),
/// make the newly installed tools visible to the current process so the
/// dependency re-check can find them.
pub fn refresh_path() {
let mut extra: Vec<PathBuf> = Vec::new();
if let Some(home) = crate::config::home_dir() {
extra.push(home.join(".cargo").join("bin"));
extra.push(home.join(".bun").join("bin"));
extra.push(home.join(".local").join("bin"));
}
let mut parts: Vec<PathBuf> = extra.into_iter().filter(|p| p.is_dir()).collect();
if let Some(current) = std::env::var_os("PATH") {
parts.extend(std::env::split_paths(&current));
}
if let Ok(joined) = std::env::join_paths(parts) {
std::env::set_var("PATH", joined);
}
}
/// Is a tool considered present? Python is satisfied by python3 too
/// (Debian ships python3 without a python symlink).
pub fn tool_is_present(tool: &str, runner: &dyn Runner) -> bool {
if tool == "python" {
runner.which("python3").is_some() || runner.which("python").is_some()
} else {
runner.which(tool).is_some()
}
}
fn scoop_command(tool: &str) -> Option<String> {
let bucket = match tool {
"git" => "git",
@@ -208,7 +272,7 @@ fn linux_command(tool: &str, pkg: &str) -> Option<String> {
("pip", "apk") => "sudo apk add py3-pip",
("pip", "zypper") => "sudo zypper install -y python3-pip",
("cargo" | "rust", "apt") => {
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"
}
("cargo" | "rust", "dnf") => "sudo dnf install -y rust cargo",
("cargo" | "rust", "pacman") => "sudo pacman -S --noconfirm rust",
@@ -278,6 +342,36 @@ mod tests {
assert!(brew_command("docker").unwrap().contains("--cask"));
}
#[test]
fn pipe_commands_run_through_shell() {
let runner = crate::runner::MockRunner::new();
let code = run_install_command(&runner, "curl -sSf https://x | sh").unwrap();
assert_eq!(code, 0);
let calls = runner.snapshot();
assert_eq!(calls.len(), 1);
if cfg!(windows) {
assert!(calls[0].starts_with("cmd /C "));
} else {
assert!(calls[0].starts_with("sh -c "));
}
}
#[test]
fn plain_commands_spawn_directly() {
let runner = crate::runner::MockRunner::new();
run_install_command(&runner, "apt install -y git").unwrap();
let calls = runner.snapshot();
assert!(calls[0].starts_with("apt install -y git"));
}
#[test]
fn python3_satisfies_python() {
let runner = crate::runner::MockRunner::new().with_found("python3");
assert!(tool_is_present("python", &runner));
let runner = crate::runner::MockRunner::new();
assert!(!tool_is_present("python", &runner));
}
#[test]
fn os_aware_selection() {
// Windows without scoop falls back to winget.
+41
View File
@@ -0,0 +1,41 @@
//! Integration test for the event journal: parses the replayable
//! fixture shipped with the repository and exercises the append path.
use agent_manager::events::{self, Event, EventKind};
use std::path::PathBuf;
fn fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/events-sample.jsonl")
}
#[test]
fn fixture_parses_valid_lines_and_skips_corrupt_ones() {
let events = events::parse_file(&fixture());
assert_eq!(events.len(), 2, "the corrupt line must be skipped");
assert_eq!(events[0].kind, EventKind::Start);
assert_eq!(events[0].agent.as_deref(), Some("claude-code"));
assert_eq!(events[0].pid, Some(1234));
assert_eq!(events[0].env_keys, vec!["OPENAI_KEY".to_string()]);
assert_eq!(events[1].kind, EventKind::Stop);
assert_eq!(events[1].duration_s, Some(1364));
assert_eq!(events[1].exit_code, Some(0));
assert_eq!(events[1].reason.as_deref(), Some("signal"));
}
#[test]
fn append_then_read_back_through_read_events() {
let dir = tempfile::tempdir().unwrap();
let mut log = events::EventLog::new(dir.path());
log.append(
&Event::now(EventKind::Run)
.with_agent("jcode")
.with_cwd("/tmp/demo")
.with_exit_code(0),
)
.unwrap();
let all: Vec<Event> = events::read_events(dir.path(), 10);
assert_eq!(all.len(), 1);
assert_eq!(all[0].agent.as_deref(), Some("jcode"));
assert_eq!(all[0].cwd.as_deref(), Some("/tmp/demo"));
assert_eq!(all[0].exit_code, Some(0));
}
+3
View File
@@ -0,0 +1,3 @@
{"ts":"2026-08-15T14:39:26Z","kind":"start","agent":"claude-code","pid":1234,"cwd":"/home/bruno/proj/api","session":"20260815_143926_a1b2c3","env_keys":["OPENAI_KEY"]}
cette ligne est corrompue et doit etre ignoree par parse_file
{"ts":"2026-08-15T15:02:10Z","kind":"stop","agent":"claude-code","pid":1234,"reason":"signal","duration_s":1364,"exit_code":0}