Compare commits

..
54 Commits
Author SHA1 Message Date
bruno b7f29baea5 fix: assistant setup — liste des providers construite depuis le registre (QwenCloud absents de la liste codee en dur, v1.1.9)
release / release (push) Successful in 13m41s
release / macos (push) Canceled after 0s
2026-09-01 11:40:05 -04:00
bruno cf53c98d98 feat: providers QwenCloud v1.1.8 — token-plan (Credits) et pay-as-you-go (Model Studio/DashScope), qwen-code configure par defaut
release / release (push) Successful in 13m39s
release / macos (push) Canceled after 0s
2026-09-01 10:20:07 -04:00
bruno 991c79de50 feat: catalogue v1.1.7 — Antigravity CLI remplace Gemini CLI (fin de service 2026-06-18)
release / release (push) Successful in 14m0s
release / macos (push) Canceled after 0s
- nouvelle entrée 'antigravity-cli' (agy, release GitHub google-antigravity/antigravity-cli, binaire Go)
- 'gemini-cli' marqué déprécié : installable: false (réservé clé API payante), note de migration
- alias gemini/agy -> antigravity-cli ; groupe mainstream et gabarit web mis à jour
- setup_hints: agy (auth trousseau) + agy plugin import gemini (import extensions/skills)
- ROADMAP v1.1.7, README 78 agents, man pages régénérées
2026-09-01 08:48:20 -04:00
bruno d911ce5d92 fix: robustesse v1.1.6 — timeout runner/doctor, anti-deadlock, securite migrate, win32 is_running, websocket RFC6455, keyring JSON
release / release (push) Successful in 13m38s
release / macos (push) Canceled after 0s
- runner.rs: timeout 30s sur les commandes capturees (doctor ne pend plus) + pipes stdout/stderr draines dans des threads (anti-deadlock) ; run_untimed() pour les installations longues (npm/cargo/go) — sh() des installateurs l'utilise, les sondes gardent le timeout

- migrate_cmd.rs: rejet des chemins unsafe (path traversal) dans import_bundle

- process.rs: is_running Windows via OpenProcess/GetExitCodeProcess (fiable vs tasklist)

- probe.rs: drain des pipes en threads dans detect_external_version

- web.rs: boucle de traduction i18n (curseur, plus de boucle infinie si la traduction contient {{)

- serve.rs: frames WebSocket RFC 6455 avec vraies longueurs > 125 octets (126/127)

- secrets.rs: index keyring serialise en JSON (cles contenant des virgules)

- sandbox.rs: tests sans fuite de TempDir (plus de mem::forget)

- version 1.1.6, man pages et ROADMAP maj
2026-08-26 10:17:17 -04:00
bruno fa2c42d505 docs: roadmap 3.1 — bilan du 2026-08-23 (reste a faire, bug doctor/runner sans timeout, 11 idees neuves) 2026-08-23 23:25:30 -04:00
bruno e637c066fe fix: am ai avec aichat >= 0.30 — rôles index.yaml + agents.txt, contexte filtré, generation --code (v1.1.5)
- roles.rs: aichat 0.30 a remplace les agents markdown agents/<name>.md
  par functions/agents/<name>/index.yaml + registre functions/agents.txt;
  generate_all ecrit les DEUX formats (compat <= 0.29 et >= 0.30)
- ai_cmd.rs: role_args detecte la definition index.yaml en premier
  ('Unknown agent am-copilot' corrige) ; contexte par defaut (dossier
  courant) filtre par am (.git, target, node_modules, binaires non-UTF-8,
  .env*, > 64 KiB, budget 128 KiB, 40 fichiers max) — aichat ne filtre
  rien et echouait sur .git/index ; exec mode utilise le role am-do
  par defaut (commande brute)
- shell_ai.rs: generation via --code au lieu de --execute --dry-run
  (aichat 0.30: --dry-run echo la requete sans appeler l'API)
- version 1.1.5, man pages et ROADMAP maj
2026-08-21 12:22:18 -04:00
bruno 66a3eebbd6 feat: hints de configuration post-install (setup_hints) — commandes manuelles affichees apres install avec substitution {model}/{provider}/{base_url} (picoclaw onboard/model/auth)
release / release (push) Failing after 2m10s
release / macos (push) Skipped
2026-08-21 11:08:22 -04:00
bruno e71cffb3c0 fix: am start <agent> passait le token run en argument (binaire enregistre existant) — picoclaw rejetait son propre nom, aichat recevait 'aichat' en prompt 2026-08-21 10:32:26 -04:00
bruno 7fb65c257d feat: am setup — provider openrouter + modeles proposes ; am ai — role am-copilot par defaut ; fix migrate import (config active)
release / release (push) Successful in 13m7s
release / macos (push) Canceled after 0s
- setup: openrouter ajoute aux CHOICES (base URL, OPENROUTER_API_KEY,
  6 modeles declares dans le registre embarqué, proposes au choix)
- setup: pick_model liste les modeles disponibles (registre) + autre modele
- am ai sans --role: am-copilot par defaut (regeneration auto des roles si
  fichier manquant, degradation douce si echec; role explicite inconnu = erreur)
- fix migrate --import: import_bundle ecrivait la config dans user_config_dir()
  GLOBAL -> ecrasait la config reelle de la machine (et cargo test detruisait
  la config utilisateur a chaque suite via export_import_round_trip).
  L'import ecrit desormais dans app.paths.config_dir (config active).
v1.1.2
2026-08-21 08:36:26 -04:00
bruno 068940495a fix: am setup — ecrire settings.providers.<nom>.base_url + default_model (top-level 'providers:' rejete par le validateur)
release / release (push) Successful in 13m11s
release / macos (push) Canceled after 0s
Le wizard persistait le modele par defaut sous la cle 'providers.<nom>.default_model'
-> bloc 'providers:' au top-level de la config user -> 'unknown field providers'
(le registre vit sous settings.providers ; le schema exige base_url quand un
bloc provider est ecrit). Regression couverte par setup_persists_under_settings_providers.
v1.1.1
2026-08-21 07:43:26 -04:00
bruno d71c3dd34d feat: epique v1.1.0 — onboarding, copilot & roles aichat (am setup)
release / release (push) Successful in 13m21s
release / macos (push) Canceled after 0s
F1 — Wizard d'onboarding (src/setup.rs) :
- am setup : provider (anthropic, openai, deepseek, google, ollama, custom),
  token masque (rpassword) -> keyring OS (jamais en clair), modele par defaut
  (liste declaree + champ libre), ping API non-bloquant
- Declencheurs non-bloquants : am ai / am ask et banner REPL proposent
  am setup quand aucun provider n'est configure ; re-executable (mode revoir)
- settings.default_provider + providers.* ecrits via persist_setting

F2 — Integration aichat :
- Etape wizard 'Installer le moteur IA (aichat) ? [Y/n]' -> am install aichat
- am ai sans aichat -> 'aichat est manquant. Installer ? [Y/n]'
- install.ps1/install.sh enchainent sur am setup si stdin interactif

F3 — Roles copilot aichat (src/roles.rs) :
- 6 agents generes dans ~/.config/aichat/agents (ou %APPDATA%\aichat\agents) :
  am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator
- Prompts bilingues (FR defaut) privilegiant les contrats --json ; le prompt
  am-copilot embarque la liste des commandes am
- am ai --role <nom> (valide le fichier genere, passe --agent a aichat),
  completion REPL ; config aichat creee seulement si absente
- Câblage complet : cli, dispatch, help, REPL, tip, man (am-setup.1), i18n

Tests : 450 verts (roles + setup unitaires, ai_test ajustes locale-agnostic)
v1.1.0 ; ROADMAP axe 14 + maintenance note ; README Premier lancement
2026-08-20 23:09:21 -04:00
bruno 50f515e406 fix: installateur Windows — accepter l'entree am-windows-x86_64.exe dans l'archive de release + i18n lang-aware pour am ai
release / release (push) Successful in 13m14s
release / macos (push) Canceled after 0s
- install.ps1 ne trouvait que 'am.exe' dans le zip : les archives (CI et
  manuelles) utilisent le nom plateforme 'am-windows-x86_64.exe' -> repli
  systematique sur cargo install au lieu du binaire precompile
- Le script accepte desormais les deux noms (copie vers am.exe) ; chemin
  du message cargo install corrige (.cargo\bin\am.exe au lieu de binam)
- i18n : am ai utilisait tr()/tr_fmt() globaux (flaky selon le process) —
  passage a tr_in/tr_fmt_in(app.lang()) + helper tr_fmt_in ajoute a i18n.rs
- Archive v1.0.6 : les deux entrees (am.exe + am-windows-x86_64.exe) pour
  la compatibilite maximale (installateurs et self-update)
- Tests ai_test insensibles a la locale (FR/EN)
- v1.0.6 ; ROADMAP maintenance note ajoutee
2026-08-20 12:45:10 -04:00
bruno 313645eea2 feat: am ai — langage naturel vers action shell securisee via AIChat (issues #96 #97)
release / release (push) Successful in 13m12s
release / macos (push) Canceled after 0s
- Commande am ai (alias CLI: am shell) : conversationnel par defaut
  (aichat -f <ctx> "<prompt>"), mode --exec avec generation aichat --dry-run
- Securite (issue #97) : classification safe/risky + certainite affichee,
  politique dry-run par defaut (settings.shell_ai.default_safety:
  dry-run | confirm | auto), confirmation y/N pour les commandes risky,
  --yes pour executer, --dry-run force la simulation
- Flags : --exec/-e, --files/-f (defaut: dossier courant), --provider,
  --model (registre providers -> env aichat + modele, token via keyring)
- Execution via le shell utilisateur (default_shell > SHELL > COMSPEC)
- Journalisation : EventKind::ShellAi (mode, risk, certainty, executed)
- Module src/shell_ai.rs (classification, politique, generation, execution)
- Câblage complet : cli, dispatch, help, REPL (parseur+completer+banner),
  tip, man pages (am-ai.1), i18n EN, config.yaml (bloc shell_ai)
- Tests : 7 unitaires (classification, certainty, extraction, decision)
  + 4 integration (shim aichat.cmd : dry-run par defaut, --yes, mode
  conversationnel, mode confirm) — 339 tests verts
- v1.0.5 ; ROADMAP axe 13.3 coche (6 lignes), epic #93 clôturee

closes #93
closes #96
closes #97
2026-08-20 12:07:38 -04:00
bruno 02517735be docs: jalons versionnes — maintenance v1.0.3/v1.0.4 dans le pied de page ROADMAP 2026-08-20 11:24:45 -04:00
bruno 0f81b7c585 feat: catalogue — 5 agents shell AI (AIChat, ShellGPT, Fabric, Shell AI, AI CLI) + alias ai (issues #94 #95)
release / release (push) Successful in 13m52s
release / macos (push) Canceled after 0s
- AIChat (sigoden/aichat) : binaire GitHub Release, alias ai -> aichat (issue #94)
- ShellGPT (TheR1D/shell_gpt) : pip shell-gpt, binaire sgpt, python >= 3.10 (issue #95)
- Fabric (danielmiessler/fabric) : go install cmd/fabric, patterns IA (issue #95)
- Shell AI (nishant9083/shell-ai) : npm @shell-ai/cli, local-first Ollama + MCP (issue #95)
- AI CLI (kriserickson/ai-cli) : binaire ai, politique de securite risk/certainty (issue #95)
- v1.0.4 : ROADMAP axe 13 coche (13.1 + 13.3), README 77 agents

closes #94
closes #95
2026-08-20 11:20:23 -04:00
bruno 0a12c617b2 docs: enrich Axe 13 Shell AI in roadmap and update architecture 2026-08-20 11:02:55 -04:00
bruno ee490f5eaf fix: détection externe — champ detect (sonde PATH explicite) pour les lanceurs interpréteurs
lazycodex (run: npx lazycodex-ai) et nanobot (run: python -m nanobot)
étaient déclarés « external » dès que npx/python étaient sur le PATH :
la sonde utilisait le premier mot de run au lieu du vrai binaire.

- AgentDef.detect : sonde PATH explicite, repli sur first_token sinon
- probe.rs : cache invalidé quand les tokens du catalogue changent
  (token_hash), plus besoin d'attendre un changement de PATH
- config.yaml : detect: lazycodex-ai / detect: nanobot
- tests : detect_token_prefers_explicit_binary + 432 tests verts
- v1.0.3, man pages régénérées
2026-08-20 10:53:59 -04:00
bruno 79e032b563 feat: Add Axe 13 (Shell AI) to roadmap 2026-08-20 10:45:10 -04:00
bruno d0f52eb7c0 fix: REPL — ask/serve/registry routées vers am au lieu du shell système
is_am_command() ne connaissait pas ask/serve/registry : le REPL les
envoyait au shell système ('command not found'). Ajout aux trois
points de contact (is_am_command, COMMAND_DESCRIPTIONS, banner) +
test élargi. Docs alignées sur v1.0 : README (ask/providers/registry/
serve), ROADMAP v2.3 (v0.7.0 + v1.0.0 livrés), ARCHITECTURE (arbre src
+ section copilote & plateforme), tips ask/serve/registry. Version 1.0.2,
man pages régénérées. CI release : cross-compile Linux musl + Windows
mingw sur un seul runner, macOS optionnel.
2026-08-20 10:22:21 -04:00
bruno 38d38d51e3 fix: install.sh acceptait seulement l'entrée 'am' dans l'archive de release
Le pipeline CI publie am-linux-x86_64.tar.gz avec une entrée nommée 'am-linux-x86_64' (pas 'am') : le test [ -f $tmpdir/am ] échouait et le script retombait sur cargo install depuis les sources alors que le binaire précompilé existe. Recherche flexible du premier fichier extrait (am | am-$platform-$arch | n'importe quel fichier). Vérifié : extraction réelle du tar.gz v1.0.1 → ELF statique trouvé.
2026-08-20 09:32:00 -04:00
bruno 5bcbc1c81f chore: version 1.0.1 (post-v1.0.0 — self-update décompresse les archives, prompt REPL avec shell actif)
release / linux (push) Successful in 15m22s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-20 08:49:25 -04:00
bruno 38297c2bf3 feat: prompt REPL — emoji 🪟 + shell actif après la version
Le statut avant chaque invite affiche maintenant le shell-gateway courant (ex: ⚙ am v1.0.0 🪟 bash │ ...), mis à jour dynamiquement via 'shell <name>'. Vérifié en réel : bash → pwsh après switch.
2026-08-20 08:35:27 -04:00
bruno ac6f2ffaa5 fix: am self-update copiait l'archive (zip/tar.gz) au lieu du binaire extrait
Les assets de release sont des archives (am-windows-x86_64.zip, am-linux-*.tar.gz) mais le self-update copiait les octets bruts téléchargés vers le binaire — le 'nouveau binaire' était un zip → 'Exec format error' au lancement. Le download des releases était en fait cassé depuis le début (jamais déclenché : l'asset Windows manquait).

- self_update: download → download::extract_archive (sniff par contenu, déjà utilisé pour les catalogues) → find_binary (préfère am/am.exe/*.exe, sinon le plus gros fichier) → copie du binaire extrait
- 2 tests : préférence am.exe dans un layout zip, fallback plus gros fichier
- E2E réel : le zip v1.0.0 extrait → 'am 1.0.0' exécutable (magic MZ)
2026-08-20 08:15:11 -04:00
bruno 2d90c48532 feat: v1.0 — am serve : API HTTP + WebSocket authentifiée pour piloter am à distance (closes #80)
release / linux (push) Successful in 15m21s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
- src/serve.rs : tiny_http (zéro dépendance runtime) — token Bearer obligatoire (comptime-ish), rate limiting fenêtre 60s par IP, routes réutilisant les contrats --json (stats/sessions/ps/providers/run/start/stop/ask) en re-spawnant le binaire (pattern web #54), WebSocket RFC 6455 (handshake 101 + accept sha1, broadcast des events du journal par polling 500ms, première passe = indexation), TLS délégué au reverse proxy (documenté)
- am serve --token <TOKEN> [--host] [--port 8080] [--rate-limit 120] + REPL + help + man
- 9 tests : auth (sans/mauvais/bon token), routes (runner mocké), run args, 404, rate limit, handshake RFC 6455 (vecteur connu), broadcast journal, comparaison token
- E2E réel : curl 401/health/stats + POST run exécutant un agent, client WS brut recevant l'event run en temps réel
- version 1.0.0 (milestone v1.0 terminé) ; test version_table suit Cargo.toml (major plus hardcodé)
2026-08-20 07:24:53 -04:00
bruno 92af59a71f feat: v1.0 — profils sandbox par agent : commandes autorisées (allowlist, stem Windows), périmètre de répertoires, politique réseau best-effort, refus journalisés pour l'audit, --no-sandbox pour contourner (closes #79)
- src/sandbox.rs : profile_of + enforce (basename/stem allowlist, cwd starts_with périmètre, proxy env si network:false), EventKind::Sandbox
- enforcement au run et au start (start_one, --parallel, restart) ; AgentDef.sandbox + doc config.yaml
- 6 tests : défaut non sandboxé, refus + journalisation, stem .exe, périmètre cwd, env réseau, bypass --no-sandbox
2026-08-19 22:57:45 -04:00
bruno 900f70c249 feat: v1.0 — am registry : registre communautaire de catalogues (publication + recherche sur Gitea), manifeste source/version/auteur/sha256, installation avec validation du checksum et décision de confiance explicite (closes #77)
- src/registry.rs : Manifest (schema/name/version/author/source/agents_count/sha256), publish (écrit am-manifest.json + instructions git), search (multi-sources via settings.registry.sources), install (manifeste → catalogue → sha256 → source cohérente → confiance → register_include), list
- settings.registry {sources, author} + doc config.yaml ; CLI/REPL/help/man/i18n
- 11 tests : hash, manifest_url, publish (fichier + refus catalogue vide/sans source), E2E install tiny_http (refus sans confiance, enregistrement avec --yes, 4 requêtes), source mismatch
2026-08-19 22:23:32 -04:00
bruno 12eb09b01a feat: v1.0 — am ask : langage naturel → commande am, règles locales FR/EN hors-ligne, raffinement LLM optionnel via le registre providers (token keyring, jamais de données du journal), cache des traductions, confirmation avant exécution, désactivable (closes #78)
- src/ask.rs : traducteur par règles (verbes FR/EN, pronoms, nicknames d'agents, séquences «et/puis»), cache ask_cache.json, POST /chat/completions du provider résolu avec Bearer du keyring, validation des commandes retournées, dégradation silencieuse sans token
- settings.ask {enabled (défaut true), provider, model} + doc config.yaml ; CLI/REPL/help/man/i18n
- 7 tests : règles (installe claude et lance-le → 2 commandes), cache, LLM via serveur tiny_http (token Bearer, zéro donnée du journal), dégradation, désactivation
2026-08-19 22:01:00 -04:00
bruno 7cab1a0b5c feat: v1.0 — télémétrie anonyme opt-in : compteurs agrégés uniquement, settings.telemetry (désactivé par défaut), batch périodique avec backoff, fire-and-forget, zéro PII (closes #76)
- src/telemetry.rs : compteurs par kind + succès/échec des runs, fichier telemetry.json à côté du state, envoi au seuil (10 events ou 7 jours), backoff 3 échecs consécutifs, reset après succès
- app.emit → maybe_record (no-op si désactivé, rien n'est même écrit localement) ; real_main → maybe_flush
- config.yaml : bloc telemetry documenté (enabled + endpoint optionnel)
- 5 tests : disabled (rien), compteurs agrégés (zéro identifiant dans le JSON), local-only sans endpoint, envoi+reset (serveur tiny_http sur thread dédié), backoff
2026-08-19 21:28:26 -04:00
bruno 6d51ce9504 feat: v0.7.0 — am run/start --provider/--model : résolution cloud via le registre (base_url + keyring + modèle), fallback cloud quand le modèle local est inconnu, canal model_env/model_arg/env_map, jamais de token en clair (closes #92)
- apply_provider : providers::resolve_for (flag > agent > défaut) + apply_env_map_resolved (api_key = @secret, base_url/model du provider résolu)
- start/run/restart : --provider et --model dans le CLI, le REPL et la complétion ; specs help + man pages
- Tests unitaires : résolution, cascade, canal déclaré, erreur provider inconnu (liste), absence de fuite (env sans valeur sk-), fallback embarqué
2026-08-19 21:05:39 -04:00
bruno 68eb170f57 feat: v0.7.0 — configuration post-install : bloc config: par agent (env_map + fichiers), adaptateurs TOML/YAML/JSON/key=value, édition douce, jamais de token en clair (api_key = @secret) (closes #91)
- Nouveau module src/agent_config.rs : apply_post_install (écriture fichiers), apply_env_map (env injectée au start/run), provider_pref (provider > config.provider_default > default_provider)
- am install écrit les fichiers de config de l'agent avec le provider/modèle résolus ; --no-config / install.configurable:false / --dry-run respectés ; agent sans adaptateur → message clair, install réussit
- Catalogue : blocs config: pour claude-code, codex, agentty (+ provider_default)
2026-08-19 20:41:34 -04:00
bruno ebc753cc4a feat: v0.7.0 — AgentDef provider/model + install --provider/--model/--no-config : résolution flag > agent > défaut, erreur si provider inconnu (liste du registre), warning si modèle absent, résolution affichée en résumé et au --dry-run (closes #90)
- Providers réels livrés dans le catalogue par défaut (anthropic★/openai/deepseek/google) + hints provider sur claude-code/deepseek-harness
- Suppression par null dans l'overlay : am providers remove écrit <nom>: null (merge traite null comme suppression, set_path remplace le null avant de descendre)
- Cascade @secret (#89) : le provider déclaré de l'agent prime sur default_provider
2026-08-19 20:03:47 -04:00
bruno 7312e6ceab feat: v0.7.0 — secrets partagés par provider : am providers set-token/token, namespace keyring providers/<nom>, résolution @secret en cascade agent → provider (closes #89)
Corrige au passage deux bugs du keyring (#36) : features natifs manquants du crate keyring (set() no-op silencieux, rien ne persistait) + index __index__ jamais purgé au remove (list fantômes).
2026-08-19 16:09:34 -04:00
bruno d38cd7352f feat: v0.7.0 — registre de providers LLM : am providers list/show/add/remove/default, settings.providers + default_provider (closes #88) 2026-08-19 15:32:45 -04:00
bruno 1948e3bc79 docs: roadmap — phase v0.7.0 (providers & configuration automatisée, épique #87, priorité P0) 2026-08-19 15:02:53 -04:00
bruno 4735fc1502 docs(readme): ajout des détails d'installation et de gestion des agents
- Emplacement des agents, binaires, état et config par OS
- Pipeline complet d'installation (catalogue, dépendances, méthodes)
- Tableau des commandes réelles par méthode d'installateur
- Cycle de gestion : démarrage, arrêt, mise à jour, désinstallation
2026-08-18 23:11:42 -04:00
bruno fa51cdb339 feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
release / linux (push) Successful in 14m16s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-18 16:40:15 -04:00
bruno 7f4d74f811 feat: phase 2 — i18n messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt, REPL lang) (closes #74) 2026-08-18 15:46:51 -04:00
bruno 4c7866a25a feat: phase 2 — am lab (benchmark N agents) + plugins d'événements (contrat JSON)
- #62: am lab --agents a,b --task <f> — séquentiel ou --parallel, capture
  durée/exit/sortie/coût estimé, rapport comparatif tableau + --json stable,
  tâches versionnables dans <state>/lab/ (--list), rejouables à l'identique
- #75: plugins/ sous state_dir, déclenchés sur on_install/on_start/on_stop/
  on_update (App::emit), contrat JSON stdin/stdout, timeout configurable,
  échec non bloquant, garde anti-récursion AM_PLUGINS_RUNNING, am plugins
  + am plugins --test <name> (CI), exemples notify + ci-webhook
- intégration REPL (parse, complétion, bannière, is_am_command), help, tips,
  man pages (am-lab.1, am-plugins.1), README, ROADMAP (26/27), v0.5.4
- 334 tests verts (309 + 25)

closes #62
closes #75
2026-08-18 14:17:30 -04:00
bruno aa0d8e43eb feat: phase 2 — playbooks (am history --save + am playbook, rejeu pas à pas) (closes #51) 2026-08-18 13:30:29 -04:00
bruno e82979e6a3 feat: phase 2 — sauvegarde git (am sync) + transfert machine (am migrate) (closes #66 #68) 2026-08-18 12:09:43 -04:00
bruno 1b3fe09074 feat: phase 2 — coûts par session (stats --costs) + am monitor TUI (closes #49 #50) 2026-08-18 11:23:50 -04:00
bruno 63dc36660b feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59) 2026-08-18 10:57:00 -04:00
bruno 6a6676fc5d feat: phase 2 — sessions export + rétention, init --template (closes #52 #53)
Axe 3 (#52) : templates embarqués dans le binaire (web, python, rust, cli)
- am init --template <stack> génère une config pré-remplie : groupe dev,
  profil par défaut, hooks projet (npm install / uv sync / cargo fetch)
- substitution {project} {dir} au rendu ; --template list pour lister
- fallback détection automatique de stack quand aucun template
- config générée validée (parse YAML + validate) par les tests

Axe 4 (#53) : am sessions export + rétention configurable
- am sessions --export <id> [--output <fichier>] : JSON complet et
  reproductible (métadonnées + commandes history/<id>.jsonl + extrait de
  log 4 Ko + rendu lisible 'readable'), compatible archivage manuel
- settings.sessions_retention_days (défaut 90) : purge au démarrage
  (silencieuse) et am sessions --retention <jours>
- purge limitée à l'index dérivé sessions.json — le journal d'événements
  primaire n'est jamais touché ; EventKind::Prune journalisé (visibilité
  am audit)
- sessions en cours (running) jamais purgées ; erreur claire si id inconnu

REPL : sessions --export/--retention/--output + init --template ; help
specs, tip cheat sheet, config.yaml, README, man pages régénérées.
Version 0.4.7, ROADMAP cochée, 276 tests verts.
2026-08-18 10:08:20 -04:00
bruno d9ffc34156 feat: phase 2 — audit config + update --rollback (closes #63 #64)
Axe 8 (sécurité) :
- am audit : checksums SHA-256 des configs (embarquée, utilisateur,
  locale) stockés dans state.json, croisement avec events.jsonl pour
  dater les changements, détection des modifications manuelles hors de
  am (statut MANUAL), rapport lisible + --json stable (contrat)
- backup automatique avant chaque am update (agent ou --all) : état +
  config utilisateur + répertoires d'installation + shims dans
  <state>/backups/<ts>/ avec manifest.json
- am update --rollback [point] : restaure le dernier backup (ou un id
  précis, préfixe accepté), arrête les agents concernés d'abord,
  événement EventKind::Rollback journalisé
- am update --rollback list : liste les points de restauration
- rétention settings.backups_keep (défaut 5) appliquée après chaque backup
- événements EventKind::Backup / EventKind::Rollback pour l'audit

REPL : commande audit + update --rollback (parse, complétion), help specs,
tip cheat sheet, man pages régénérées (50 pages). Version 0.4.6,
ROADMAP cases cochées, 272 tests verts.
2026-08-18 09:44:16 -04:00
bruno 22bc529818 feat: phase 2 — modèles locaux + catalogue distant + suggest (closes #70 #71 #73 #60 #67 #61)
Axe 11 (modèles) :
- am models : inventaire des runtimes locaux (ollama list --format json,
  llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon
  probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale
- am run <agent> --model <m> : résolution dans l'inventaire, transmission
  via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session
- am models --prune : candidats inutilisés depuis settings.models_prune_days
  (défaut 30), :latest toujours protégé, dry-run + confirmation + événement
  EventKind::Model journalisé

Axes 7/9 (catalogue) :
- am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/
  modifiés), confirmation, enregistrement en include
- am catalog add <url> : validation du format (parse + validate), cache
  catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé
- include par URL (http/https/file) dans config.yaml, résolu au chargement
  avec cache + TTL ; override local toujours prioritaire (issue #67)
- am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par
  usage réel (lancements + durée depuis le journal), top 5 justifié, --json

REPL : commandes models/catalog/suggest (parse, complétion, descriptions),
help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles).
Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
2026-08-18 09:06:16 -04:00
bruno 890ad953e5 feat: phase 2 quick wins (#65 #69 #72)
- #65: update policies + per-agent version pinning
  - add settings.update_policy (stable/latest/skip)
  - add AgentDef.pin_version
  - honor both in update_cmd
  - add update_policy tests

- #69: REPL color theme CLI command
  - add am theme [name] (show/switch)
  - persist selected theme to config

- #72: doctor checks local model runtimes
  - verify ollama and llama-server on PATH
  - for ollama, also check server responds (ollama ls)

Version 0.4.4
2026-08-17 22:24:45 -04:00
bruno 98c0b30f68 feat: am tip cheat sheet + readable man pages + REPL completion refresh 2026-08-17 21:50:48 -04:00
bruno 53893f2967 v0.4.3 : persistance du theme actif choisi
release / linux (push) Successful in 13m24s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
- le REPL 'theme <name>' applique ET sauvegarde le theme (settings.theme dans le fichier de config actif, cree s'il manque) via persist_setting
- correction racine : config::merge ignorait settings.theme - la valeur persistee n etait jamais relue au chargement, le theme par defaut etait toujours reapplique apres un redemarrage
- 'am --theme <name>' reste un one-shot ; config set settings.theme <nom> fonctionne egalement
- aides mises a jour (banniere, help theme)
- tests de persistance + relecture au demarrage, man pages regenerees, version 0.4.3
2026-08-17 15:23:28 -04:00
bruno 91253b79c5 ci : files de gitea-release-action en bloc YAML (retours a la ligne) - l'action splitte sur \n, le bloc plie en une ligne et ne trouvait aucun fichier ('not include valid file' -> rien n'etait uploade)
release / linux (push) Successful in 13m21s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-17 14:38:13 -04:00
bruno 3c8bb93b8a ci : installer nodejs dans le conteneur avant actions/checkout (le post-step checkout exige node, absent de rust:1.94-alpine) - le job linux echouait avec 'exec: node: executable file not found' apres la reussite du pull d'image
release / linux (push) Successful in 13m4s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-17 14:20:11 -04:00
bruno 363d5e3286 ci : documente l'espace disque requis sur le runner dans le workflow
release / linux (push) Failing after 48s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-17 14:17:09 -04:00
bruno 4c60ab5bcc docs : methode de build linux musl avec cargo-zigbuild (aarch64 + x86_64) dans RELEASING.md
release / linux (push) Failing after 22s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-17 13:42:53 -04:00
bruno e24e518846 ci : job linux avec cargo-zigbuild (aarch64 musl) + empreinte disque reduite - rust:1.94-alpine n'a pas de compilateur croise aarch64 (le step aarch64 echouait avec 'aarch64-linux-musl-gcc not found') ; cargo-zigbuild produit x86_64 + aarch64 musl avec zig (~50 Mo) en une seule image ; CARGO_TARGET_DIR=/tmp + CARGO_INCREMENTAL=0 pour ne rien laisser sur le runner ; concurrency cancel-in-progress ; le setup job peut aussi echouer sur disque plein (no space left on device) : nettoyer docker sur l'hote
release / linux (push) Failing after 25s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-17 13:38:07 -04:00
bruno 2c079b0ccd v0.4.2 : menu de completion Nushell (reedline) + correction des chemins Windows - remplace rustyline par reedline (menu Tab interactif : 1er choix surbrille, Tab navigue, Entree valide, Esc ferme, filtrage en direct, descriptions, couleurs du theme), tokenizer Windows preservant les backslashes pour cd/ls (shell_words mangeait les \), historique history.txt conserve, repli sans terminal, tests + man pages regeneres
release / linux (push) Failing after 24s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
2026-08-17 13:08:50 -04:00
148 changed files with 24340 additions and 870 deletions
+86 -54
View File
@@ -1,18 +1,30 @@
# Gitea Actions : pipeline de release (issue #42).
#
# Sur chaque tag v* : construit les binaires de toutes les plateformes,
# archive + publie la release Gitea avec les artefacts. Les installateurs
# (install.sh / install.ps1) et 'am self-update' consomment ces artefacts.
# Sur chaque tag v* : construit les binaires de toutes les plateformes.
# Le job principal 'release' cross-compile Linux + Windows depuis un seul
# runner ubuntu-latest, donc il n'a pas besoin de runners Windows/macOS.
# Le job 'macos' est optionnel : s'il y a un runner macos-latest, il ajoute
# les archives macOS a la release existante ; sinon l'installateur retombe
# sur cargo install --git sur macOS.
#
# Labels de runners attendus : ubuntu-latest, windows-latest, macos-latest.
# Un runner manquant laisse son job en attente ; le job linux couvre la
# majorite des cas (les artefacts windows/macos peuvent aussi etre produits
# par les scripts locaux documentes dans RELEASING.md).
# Les installateurs one-liner (install.sh / install.ps1) et 'am self-update'
# consomment les artefacts attaches a la release.
#
# Prérequis du runner : pouvoir tirer les images docker.io (rust:1.94-alpine
# ici, ou les images messense/rust-musl-cross:x86_64-musl / :aarch64-musl) et
# le secret GITEA_TOKEN (jeton avec droit 'write:release') positionné sur le
# dépôt. La release existante est écrasée (release_overwrite).
# Cross-compilation utilisee :
# - Linux x86_64 + aarch64 : cargo-zigbuild + musl (binaires statiques)
# - Windows x86_64 : mingw-w64 + x86_64-pc-windows-gnu
#
# Prerequis du runner : pouvoir tirer l'image docker.io (rust:1.94-bookworm,
# ~1,2 Go) et le secret GITEA_TOKEN (jeton avec droit 'write:release')
# positionne sur le depot.
#
# Note disque (runner avec espace limite) :
# - il faut ~2 Go libres sur / pour tirer l'image (rust:1.94-bookworm) ;
# en cas de 'no space left on device' : docker system prune -af --volumes
# sur l'HOTE du runner (pas une autre machine !) ;
# - les builds compilent dans CARGO_TARGET_DIR=/tmp (hors du volume
# workspace) avec l'incremental desactive, donc rien ne persiste ;
# - zig (~50 Mo) fournit le linker croise pour Linux.
name: release
on:
@@ -20,68 +32,85 @@ on:
tags:
- "v*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
release:
runs-on: ubuntu-latest
container: rust:1.94-alpine
container: rust:1.94-bookworm
env:
CARGO_TARGET_DIR: /tmp/am-target
CARGO_INCREMENTAL: "0"
steps:
# actions/checkout (et son post-step) s'execute via node, absent de
# l'image rust:bookworm par defaut : sans nodejs le job echoue des le checkout.
- name: Prepare container (node for checkout action)
run: apt-get update && apt-get install -y nodejs
- uses: actions/checkout@v4
- name: Install build tools
run: apk add --no-cache build-base zip
- name: Install build tools (zig + cargo-zigbuild + mingw)
run: |
apt-get update
apt-get install -y build-essential mingw-w64 zip curl
rustup target add \
x86_64-unknown-linux-musl \
aarch64-unknown-linux-musl \
x86_64-pc-windows-gnu
curl -fsSL https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz -o /tmp/zig.tar.xz
tar -xf /tmp/zig.tar.xz -C /opt
echo "/opt/zig-x86_64-linux-0.14.1" >> "$GITHUB_PATH"
cargo install cargo-zigbuild --locked
# Contourne un bug de casse dans certaines crates Windows
# (ex. windows-sys peut emettre -lKernel32 au lieu de -lkernel32).
# Le linker MinGW sous Linux est sensible a la casse.
ln -sf /usr/x86_64-w64-mingw32/lib/libkernel32.a \
/usr/x86_64-w64-mingw32/lib/libKernel32.a
- name: Add musl targets
run: rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
- name: Build linux x86_64 + aarch64 (static musl, zig)
run: cargo zigbuild --release --locked --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl
- name: Build linux x86_64 (static musl)
run: cargo build --release --locked --target x86_64-unknown-linux-musl
- name: Build linux aarch64 (static musl)
run: cargo build --release --locked --target aarch64-unknown-linux-musl
- name: Build windows x86_64 (mingw)
run: cargo build --release --locked --target x86_64-pc-windows-gnu
- name: Stage archives
run: |
mkdir -p dist
cp target/x86_64-unknown-linux-musl/release/am dist/am-linux-x86_64
cp target/aarch64-unknown-linux-musl/release/am dist/am-linux-aarch64
cp "$CARGO_TARGET_DIR/x86_64-unknown-linux-musl/release/am" dist/am-linux-x86_64
cp "$CARGO_TARGET_DIR/aarch64-unknown-linux-musl/release/am" dist/am-linux-aarch64
cp "$CARGO_TARGET_DIR/x86_64-pc-windows-gnu/release/am.exe" dist/am-windows-x86_64.exe
cd dist
tar -czf am-linux-x86_64.tar.gz am-linux-x86_64
tar -czf am-linux-aarch64.tar.gz am-linux-aarch64
sha256sum am-*.tar.gz > checksums.txt
zip -q am-windows-x86_64.zip am-windows-x86_64.exe
rm am-windows-x86_64.exe
sha256sum am-linux-*.tar.gz am-windows-*.zip > checksums.txt
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: dist/am-linux-x86_64.tar.gz
# L'action splitte 'files' sur les retours a la ligne (pas les
# espaces) : un bloc YAML plie en une seule ligne et ne trouve
# alors aucun fichier -> 'not include valid file', rien n'upload.
files: |
dist/am-linux-x86_64.tar.gz
dist/am-linux-aarch64.tar.gz
dist/checksums.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build windows x86_64
run: cargo build --release --locked
- name: Stage archive
run: |
New-Item -ItemType Directory -Force dist | Out-Null
Compress-Archive -Path target/release/am.exe -DestinationPath dist/am-windows-x86_64.zip -Force
(Get-FileHash dist/am-windows-x86_64.zip -Algorithm SHA256).Hash.ToLower() | Out-File dist/checksums.txt
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: dist/am-windows-x86_64.zip
dist/am-windows-x86_64.zip
dist/checksums.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true
macos:
# Optionnel : ajoute les archives macOS si un runner macos-latest est
# disponible. Sinon ce job reste en attente, mais le job release a deja
# publie Linux + Windows.
needs: release
runs-on: macos-latest
env:
CARGO_TARGET_DIR: /tmp/am-target
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
@@ -94,18 +123,21 @@ jobs:
- name: Stage archives
run: |
mkdir -p dist
cp target/x86_64-apple-darwin/release/am dist/am-macos-x86_64
cp target/aarch64-apple-darwin/release/am dist/am-macos-aarch64
cp "$CARGO_TARGET_DIR/x86_64-apple-darwin/release/am" dist/am-macos-x86_64
cp "$CARGO_TARGET_DIR/aarch64-apple-darwin/release/am" dist/am-macos-aarch64
cd dist
tar -czf am-macos-x86_64.tar.gz am-macos-x86_64
tar -czf am-macos-aarch64.tar.gz am-macos-aarch64
shasum -a 256 am-*.tar.gz > checksums.txt
shasum -a 256 am-macos-*.tar.gz > checksums-macos.txt
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: dist/am-macos-x86_64.tar.gz
files: |
dist/am-macos-x86_64.tar.gz
dist/am-macos-aarch64.tar.gz
dist/checksums.txt
dist/checksums-macos.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true
# false = ajoute les assets macOS a la release creee par le job
# principal, sans ecraser Linux + Windows.
release_overwrite: false
+787
View File
@@ -0,0 +1,787 @@
# 🏗️ Document d'architecture — agent-manager (`am`)
> Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux.
>
> 📦 Version : `0.6.0` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
---
## 🎯 Vue d'ensemble
`am` est un gestionnaire d'agents IA de coding qui permet de :
- 📋 Découvrir et lister plus de **72 agents** via un catalogue YAML embarqué
- 📦 Les installer avec **9 méthodes** différentes (npm, pip, uv, cargo, go, bun, curl, binaire, git)
- 🚀 Les démarrer, arrêter, redémarrer en avant-plan ou en arrière-plan
- 🔍 Observer leur état, leurs logs, leurs statistiques d'utilisation
- 🛠️ Gérer dépendances, alias, groupes, profils, secrets, favoris et annotations
- ⚡ Exécuter des actions shell via langage naturel avec un agent léger (`am ai`)
Le tout sans toucher au système : chaque agent est installé dans un répertoire utilisateur isolé.
---
## 🧩 Architecture globale
```mermaid
flowchart TB
subgraph UI["🖥️ Interfaces utilisateur"]
CLI["Ligne de commande<br/>clap v4"]
REPL["Shell interactif<br/>rustyline"]
TUI["Dashboard TUI<br/>ratatui + crossterm"]
WEB["Dashboard web<br/>tiny_http (127.0.0.1)"]
end
subgraph CORE["⚙️ Noyau applicatif"]
APP["App<br/>contexte partagé"]
CLI_DEF["cli.rs<br/>définition des commandes"]
CMD["commands/<br/>dispatch"]
SHELL_AI["shell_ai.rs<br/>agent shell léger"]
end
subgraph DATA["💾 Données persistantes"]
CONFIG["Configuration YAML<br/>embarquée + utilisateur"]
CATALOG["Catalog<br/>index agents/alias/groupes"]
STATE["StateStore<br/>state.json"]
EVENTS["Journal JSONL<br/>events-YYYYMM.jsonl"]
CACHE["Probe cache<br/>probe-cache.json"]
end
subgraph EXEC["🔧 Exécution"]
RUNNER["Runner trait<br/>système / mock"]
INSTALLERS["installers/<br/>9 méthodes"]
DEPS["deps.rs<br/>vérification dépendances"]
PROCESS["process.rs<br/>PID / signaux"]
end
subgraph OBS["👁️ Observation"]
PROBE["probe.rs<br/>détection agents externes"]
DASH["dashboard.rs<br/>agrégation"]
STATS["stats.rs<br/>statistiques"]
SESSIONS["sessions.rs<br/>registre sessions"]
end
CLI --> APP
REPL --> APP
TUI --> APP
WEB --> APP
APP --> CLI_DEF
APP --> SHELL_AI
APP --> CONFIG
APP --> CATALOG
APP --> STATE
APP --> EVENTS
APP --> CACHE
CMD --> RUNNER
CMD --> INSTALLERS
CMD --> DEPS
CMD --> PROCESS
CMD --> PROBE
CMD --> DASH
CMD --> STATS
CMD --> SESSIONS
SHELL_AI --> CMD
```
---
## 🚀 Flux d'exécution d'une commande
```mermaid
sequenceDiagram
autonumber
participant User
participant main as main.rs
participant lib as lib.rs
participant help as help.rs
participant cli as cli.rs
participant app as app.rs
participant cfg as config.rs
participant state as state.rs
participant cmd as commands/mod.rs
participant impl as commands/xxx_cmd.rs
User->>main: am install jcode
main->>lib: main_entry()
lib->>lib: spawn thread 8 Mo
lib->>help: intercept(-h/--help) ?
help-->>lib: None
lib->>cli: Cli::parse()
cli-->>lib: Cli { command: Install {...} }
lib->>app: App::from_cli(cli)
app->>cfg: load() config embarquée + user
cfg-->>app: Config
app->>state: StateStore::new()
state-->>app: state.json chargé
app-->>lib: App
lib->>cmd: commands::execute(&app)
cmd->>impl: install_cmd::run(...)
impl->>impl: vérifier dépendances, installer
impl->>state: persister
impl->>app: emit(event install)
impl-->>cmd: Ok(0)
cmd-->>lib: 0
lib-->>main: code sortie
```
---
## 📁 Structure du code source
```
src/
├── main.rs 🚪 Point d'entrée
├── lib.rs 🧭 main_entry(), doc du crate
├── cli.rs 📋 Définition clap de toutes les commandes
├── app.rs 🧰 Contexte App (config, state, paths, logger, theme)
├── config.rs ⚙️ Schéma YAML, chargement, fusion, validation
├── catalog.rs 🔍 Index agents + recherche fuzzy + suggestions
├── catalog_remote.rs 🌐 Catalogues distants (fetch, cache, includes)
├── state.rs 💾 Base JSON des installations et annotations
├── events.rs 📝 Journal d'événements JSONL
├── runner.rs 🏃 Trait d'exécution système / mock
├── process.rs ⚙️ Lancement, arrêt, signaux des processus
├── deps.rs ✅ Vérification et auto-installation des dépendances
├── toolchain.rs 🖥️ Détection OS / gestionnaire de paquets
├── download.rs ⬇️ Téléchargement, checksums, extraction
├── probe.rs 🔎 Détection des agents externes sur le PATH
├── dashboard.rs 📊 Données agrégées du dashboard
├── ps.rs 🧮 Table des processus
├── context.rs 📂 Contexte du projet courant
├── repl.rs 💬 Shell interactif
├── shell.rs 🐚 Gestion des shells supportés
├── help.rs ❓ Aide Nushell-style
├── output.rs 🖨️ Logger et rendu
├── tables.rs 📋 Rendu tabulaire
├── theme.rs 🎨 Thèmes de couleur (REPL et tableaux)
├── web.rs 🌐 Serveur HTTP local (127.0.0.1) + API JSON + frontend embarqué
├── serve.rs 🚀 API HTTP + WebSocket authentifiée (pilotage à distance, issue #80)
├── frontend/ 📄 index.html — dashboard web (HTML5 + CSS + JS vanilla, include_str!)
├── history.rs ⏪ Historique des commandes
├── sessions.rs 📅 Registre des sessions
├── projects.rs 🗂️️ Agrégation par projet
├── hooks.rs 🪝 Hooks de cycle de vie
├── secrets.rs 🔒 Gestion des secrets (keyring OS)
├── providers.rs 🏷️ Registre de providers LLM (base_url, modèles, défaut, @secret)
├── registry.rs 📦 Registre communautaire de catalogues (manifeste + sha256)
├── ask.rs 💬 Langage naturel → commandes am (règles locales + LLM optionnel)
├── sandbox.rs 🛡️ Profils sandbox par agent (allowlist, périmètre, réseau)
├── telemetry.rs 📈 Télémétrie anonyme opt-in (compteurs agrégés)
├── lab.rs 🧪 Benchmark d'agents (tâches YAML versionnables)
├── playbook.rs ▶️ Rejeu pas à pas d'une séquence d'historique
├── plugins.rs 🔌 Scripts d'extension sur les événements (contrat JSON)
├── models.rs 🤖 Inventaire des modèles locaux (ollama, llama.cpp, LM Studio)
├── shell_ai.rs ⚡ Agent shell léger : langage naturel → action shell
├── sync.rs 🔄 Push git de l'état (journal, sessions, config)
├── agent_config.rs ⚙️ Adaptateurs de configuration post-install (TOML/YAML/JSON/key=value)
├── costs.rs 💰 Coûts estimés par agent (tokens in/out, modèles de prix)
├── automation.rs ⚙️ Services système + tâches planifiées (systemd/launchd/schtasks)
├── backup.rs 💾 Sauvegardes (update --rollback, migrate)
├── doctor.rs 🩺 Diagnostics environnement + --fix
├── nav.rs 🧭 Tables de navigation (ls/dir)
├── i18n.rs 🌍 Catalogue de traductions FR/EN (--lang, AM_LANG, LANG)
├── version.rs 🏷️ Informations de version
├── commands/ 📦 51 modules, un par commande
└── installers/ 📦 8 installateurs spécialisés
```
---
## ⚙️ Configuration : catalogue YAML extensible
### Hiérarchie de chargement
```mermaid
flowchart LR
EMB["📦 config.yaml<br/>embarqué dans le binaire"] --> MERGE["🔀 Fusion"]
USER["👤 ~/.config/agent-manager/config.yaml"] --> MERGE
LOCAL["📂 ./agent-manager.yaml"] --> MERGE
FLAG["⚡ --config <file>"] --> MERGE
MERGE --> EFFECTIVE["✅ Configuration effective"]
```
Priorité (la plus prioritaire en dernier) :
1. `config.yaml` embarqué (catalogue par défaut, ~72 agents)
2. `~/.config/agent-manager/config.yaml`
3. `./agent-manager.yaml` (configuration locale par projet)
4. `--config <fichier>`
### Schéma de configuration
```yaml
version: "1.0"
settings:
install_dir: null # ~/.local/share/agent-manager/agents
log_dir: null # ~/.local/state/agent-manager/logs
default_shell: null # shell utilisateur par défaut
auto_install_deps: true # proposer d'installer les dépendances manquantes
confirm_before_run: true # confirmer avant les scripts d'installation
stop_timeout_secs: 5 # délai SIGTERM → SIGKILL
self_update_repo: Projets/agent-manager
self_update_base_url: https://git.dracodev.net/api/v1
theme: null
hooks:
on_install: []
on_start: []
on_stop: []
on_update: []
aliases:
cc: claude-code
gemini: antigravity-cli
groups:
dev: [claude-code, aider, codex]
include:
- ./extra-agents.yaml
agents:
- name: claude-code
display_name: "Claude Code"
description: "..."
category: coding-agent
website: https://github.com/anthropics/claude-code
install:
type: npm
package: "@anthropic-ai/claude-code"
dependencies:
- { name: node, min_version: "18.0.0" }
run: claude
tags: [anthropic, assistant]
profiles:
dev:
agent: claude-code
env: { API_ENV: dev }
args: [--verbose]
projects:
mon-projet:
root: ~/projets/mon-projet
default_agent: claude-code
env: { KEY: value }
```
### Définition d'un agent (`AgentDef`)
| Champ | Description |
|-------|-------------|
| `name` | Identifiant unique (slug) |
| `display_name` | Nom lisible |
| `description` | Description longue |
| `category` | Catégorie (coding-agent, assistant, local-first...) |
| `website` | URL du projet |
| `install` | Spécification d'installation |
| `dependencies` | Outils requis (node, python, go...) avec version min |
| `run` | Commande de lancement |
| `args` | Arguments par défaut |
| `env` | Variables d'environnement par défaut |
| `tags` | Tags pour recherche/filtrage |
| `installable` | `false` pour les agents SaaS/Desktop |
| `platforms` | Restriction `linux`/`macos`/`windows` |
### Méthodes d'installation supportées
| Type | Fichier | Principe |
|------|---------|----------|
| `npm` | `installers/npm.rs` | `npm install -g --prefix <root>` |
| `bun` | `installers/npm.rs` | `bun install -g` |
| `pip` / `uv` | `installers/pipuv.rs` | virtualenv privé dans `install_dir` |
| `cargo` | `installers/cargo.rs` | `cargo install --root` |
| `go` | `installers/golang.rs` | `go install` avec `GOBIN` local |
| `curl` | `installers/script.rs` | Télécharge et exécute un script |
| `binary` | `installers/binary.rs` | Release GitHub/Gitea → extraction archive |
| `git` | `installers/git.rs` | Clone + build + `binary_path` |
---
## 💾 Gestion des états
### `state.json` — base d'installation locale
Format JSON version 3, atomique (écriture `.tmp` + `rename`).
```json
{
"version": 3,
"installed": {
"jcode": {
"name": "jcode",
"version": "0.76.0",
"method": "binary",
"directory": ".../agents/jcode",
"binaries": ["jcode"],
"pid": null,
"installed_at": "...",
"updated_at": "..."
}
},
"annotations": {
"jcode": {
"favorite": true,
"note": "mon agent préféré",
"tags": ["perso"]
}
}
}
```
### Journal d'événements JSONL
- Un fichier par mois : `events-YYYYMM.jsonl`
- Append-only, horodaté
- Types d'événements : `start`, `stop`, `run`, `install`, `update`, `uninstall`, `doctor`, `config`, `repl`, `shell`, `annotate`
- Les secrets ne sont jamais journalisés
- Source de vérité pour les statistiques, sessions, projets et timeline
### Cache de sondes
`probe-cache.json` évite de re-scanner le PATH à chaque commande pour détecter les agents externes et leurs versions.
---
## 🛡️ Gestion des dépendances
```mermaid
flowchart LR
INSTALL["am install jcode"] --> CHECK["deps::check_dep"]
CHECK --> MISSING{Manquant ?}
MISSING -->|Oui| DETECT["toolchain::detect OS"]
DETECT --> COMMANDS["toolchain::install_commands"]
COMMANDS --> PROMPT["Proposer la commande"]
PROMPT --> AUTO["Exécuter si --yes"]
AUTO --> INSTALL2["installers::run_install"]
MISSING -->|Non| INSTALL2
```
- `deps.rs` vérifie chaque dépendance via `<outil> --version`
- `toolchain.rs` détecte l'OS et le gestionnaire de paquets :
- Windows : `scoop` → `winget`
- Linux : `apt` / `dnf` / `pacman` / `apk`
- macOS : `brew`
- Si `auto_install_deps: true`, `am` propose et peut exécuter la commande d'installation
- Les dépendances de l'agent (modules npm/pip) restent isolées dans le répertoire `install_dir`
---
## ⚡ Gestion des processus
```mermaid
flowchart TB
START["am start <agent>"] --> MODE{Mode ?}
MODE -->|Foreground| FG["Attache au terminal<br/>Ctrl-C pour quitter"]
MODE -->|Background| BG["spawn_background()"]
BG --> LOG["Redirection stdout/stderr<br/>vers log agent"]
LOG --> PID["Enregistrement PID<br/>dans state.json"]
PID --> EVENT["Émission événement start"]
STOP["am stop <agent>"] --> SIGTERM["SIGTERM"]
SIGTERM --> WAIT{"Processus terminé ?"}
WAIT -->|Non| SIGKILL["SIGKILL après timeout"]
WAIT -->|Oui| CLEAN["PID effacé"]
```
- Démarrage avant-plan (`-f`) ou arrière-plan (`-b`, `--background`)
- Variables d'environnement injectées via `--env KEY=VALUE`
- Secrets via `--env KEY=@secret` (résolu depuis le trousseau OS)
- Arrêt gracieux avec timeout configurable (défaut 5 s)
- Notifications desktop optionnelles (`--notify`)
---
## 📋 Commandes détaillées
### 🔍 Découvrir
| Commande | Description |
|----------|-------------|
| `am list` | Agents installés + détectés sur le PATH |
| `am list --all` | Tout le catalogue avec état |
| `am search <mot>` | Recherche fuzzy avec suggestions |
| `am info <agent>` | Fiche détaillée |
| `am status [agent]` | État, version, PID, logs |
### 📦 Cycle de vie
| Commande | Description |
|----------|-------------|
| `am install <agent>` | Installe un agent et ses dépendances |
| `am install <agent> --method pip` | Choisit la méthode |
| `am uninstall <agent> [--purge]` | Désinstalle et nettoie |
| `am update <agent>` / `--all` | Met à jour |
| `am start [agent]` | Lance en avant-plan |
| `am start <agent> -b` | Lance en arrière-plan |
| `am stop <agent>` | Arrêt gracieux |
| `am restart <agent>` | Redémarrage |
| `am run <agent> [args...]` | Exécution directe sans gestion de PID |
### 👁️ Observer
| Commande | Description |
|----------|-------------|
| `am logs <agent>` | Tail du log agent |
| `am log [agent]` | Journal des événements `am` |
| `am timeline` | Vue chronologique unifiée |
| `am sessions [agent]` | Registre des sessions |
| `am stats [agent] --period 7d` | Statistiques d'utilisation |
| `am top --period 30d` | Top 10 agents |
| `am report --last-week` | Rapport markdown |
| `am projects [nom]` | Agents par projet |
| `am dashboard` | Dashboard TUI temps réel |
| `am watch <agent> --restart` | Supervision et relance automatique |
### 🎯 Personnaliser
| Commande | Description |
|----------|-------------|
| `am favorite <agent>` | Marquer comme favori |
| `am note <agent> <texte>` | Ajouter une note |
| `am tag <agent> <tag>` | Taguer |
| `am tags [agent]` | Lister les tags |
| `am profile list/show <name>` | Profils d'environnement |
| `am alias add <nom> <cible>` | Créer un alias |
| `am secret set <nom> --agent <a> --value <v>` | Secret dans le trousseau |
### 🛠️ Système
| Commande | Description |
|----------|-------------|
| `am doctor [--fix]` | Diagnostic environnement |
| `am config show/path/edit/validate/set` | Gestion config |
| `am completion <shell>` | Script de complétion |
| `am man [commande]` | Page de manuel |
| `am open <agent>` | Ouvrir le répertoire d'installation |
| `am export/import` | Sauvegarde/restauration |
| `am self-update [--check]` | Mise à jour de `am` |
| `am self-uninstall` | Désinstallation complète |
| `am version` | Version et build |
| `am` (sans commande) | REPL interactif |
### 🤖 Copilote & plateforme (v0.7.0 / v1.0)
| Commande | Description |
|----------|-------------|
| `am ask "<demande>"` | Langage naturel → commande(s) am : règles locales FR/EN hors-ligne, raffinement LLM optionnel (`settings.ask`), confirmation avant exécution |
| `am providers list/add/remove/set-token` | Registre LLM centralisé (base_url, modèles, clé par provider au keyring, résolution `@secret`) |
| `am registry publish/search/install` | Registre communautaire de catalogues (Gitea, manifeste + checksum sha256 vérifié) |
| `am serve --token [--port]` | API HTTP + WebSocket authentifiée : stats, run, start, stop, ask — rate limiting par IP, TLS derrière reverse proxy |
| `am web [--port]` | Dashboard web local en lecture seule (127.0.0.1), contrats `--json` réutilisés |
| `am ai "<prompt>" [--exec] [--files <path>]` | **Shell AI** : langage naturel → commande/action shell via agent léger (AIChat) ; `--dry-run` par défaut, confirmation avant exécution |
| `am lab --agents a,b --task <f>` | Benchmark comparatif (durée, exit, coût) sur tâches YAML versionnables |
| `am sync [--message]` | Sauvegarde git de l'état (journal, sessions, config — secrets exclus) |
| `am migrate export/import` | Bundle de transfert machine A → B (config + état + historique) |
| `am schedule add/list/remove/run` | Planification de commandes am (cron / Task Scheduler, issue #56) |
| `am service install <agent>` | Service système (systemd / launchd / tâche Windows, autostart) |
| `am monitor [--json]` | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils |
| `am models [--prune]` | Inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| `am audit` | Qui a modifié quoi, quand (checksums config + journal) |
| `am plugins [--test <nom>]` | Scripts d'extension sur les événements (contrat JSON stdin/stdout) |
---
## 🌐 Options globales
Disponibles avant ou après la sous-commande.
| Option | Effet |
|--------|-------|
| `-c, --config <FILE>` | Fichier de configuration alternatif |
| `-v, --verbose` | Affiche chaque commande exécutée |
| `-q, --quiet` | Seules les erreurs sont affichées |
| `-y, --yes` | Oui à toutes les confirmations |
| `--dry-run` | Simulation sans modification |
| `--json` | Sortie JSON structurée |
| `--no-color` | Désactive les couleurs |
| `--theme <THEME>` | Thème de couleur |
---
## 🎨 Fonctionnalités avancées
### Alias
```yaml
aliases:
cc: claude-code
gemini: antigravity-cli
```
`am start cc` démarre `claude-code`.
### Groupes
```yaml
groups:
dev: [claude-code, aider, codex]
```
`am start group:dev` démarre tous les agents du groupe en arrière-plan.
### Profils d'environnement
```yaml
profiles:
dev:
agent: claude-code
env: { API_ENV: dev }
args: [--verbose]
```
`am start --profile dev` lance `claude-code` avec les variables et arguments du profil.
### Projets
```yaml
projects:
mon-projet:
root: ~/projets/mon-projet
default_agent: claude-code
```
Dans le répertoire du projet, `am start` sans argument lance l'agent par défaut du projet.
### Secrets
Les secrets sont stockés dans le trousseau du système d'exploitation (via `keyring`), jamais en clair dans la config.
```bash
am secret set OPENAI_API_KEY --agent claude-code --value sk-...
am start claude-code --env OPENAI_API_KEY=@secret
```
### Hooks
Commandes exécutées automatiquement aux étapes clés du cycle de vie :
- `on_install`
- `on_start`
- `on_stop`
- `on_update`
Définissables globalement dans `settings.hooks` ou par projet.
### Shell interactif (REPL)
Lancé par `am` sans sous-commande :
- Complétion Tab personnalisée (commandes, agents, alias, groupes, flags, shells, thèmes)
- Historique des commandes
- Passerelle système : commandes inconnues exécutées dans le shell actif
- Commandes internes : `ls`, `dir`, `cd`, `ps`, `where`, `get`, `shell`, `theme`, `exit`, `/help`
- Bannière ASCII art
---
## 📊 Dashboard TUI
```mermaid
flowchart LR
DASH["am dashboard"] --> OVER["Overview"]
DASH --> ACT["Activité"]
DASH --> STATS["Statistiques"]
DASH --> SESS["Sessions"]
DASH --> PROJ["Projets"]
```
- Navigation : `Tab` / `←` / `→` pour les onglets
- Défilement : `j` / `k` ou flèches
- Quitter : `q`
- Données agrégées depuis `state.json`, le journal d'événements, les sessions et les projets
---
## 🧪 Tests
### Organisation
- **Tests unitaires** : dans chaque module `src/*.rs` sous `#[cfg(test)]`
- **Tests d'intégration** : dans `tests/`
### Fichiers de tests notables
| Fichier | Couverture |
|---------|------------|
| `annotations_test.rs` | Favoris, notes, tags |
| `config_test.rs` | Chargement/validation/fusion |
| `dashboard_test.rs` | Dashboard |
| `doctor_test.rs` | `am doctor` |
| `dry_run_test.rs` | Mode `--dry-run` |
| `events_test.rs` | Journal d'événements |
| `history_test.rs` | Historique |
| `process_test.rs` | Processus |
| `profiles_test.rs` | Profils |
| `projects_test.rs` | Projets |
| `sessions_test.rs` | Sessions |
| `stats_test.rs` | Statistiques |
Lancer les tests :
```bash
cargo test
```
---
## 📦 Packaging et distribution
```
dist/ Binaires précompilés
├── am-linux-x86_64
├── am-linux-aarch64
├── am-windows-x86_64.zip
├── am_0.4.3_amd64.deb
└── ...
packaging/ Scripts de packaging
├── deb/
├── rpm/
├── homebrew/
├── scoop/
└── winget/
completions/ Scripts de complétion
├── _am zsh
├── _am.ps1 PowerShell
├── am.bash bash
├── am.elv elvish
└── am.fish fish
man/ Pages de manuel générées
```
- `build.rs` enregistre le commit et la branche git pour `am version`
- `scripts/build-release.ps1` construit les releases multiplateformes
- `scripts/render-manifests.ps1` génère les manifests de packaging
---
## 🗂️️ Arborescence des données utilisateur
### Windows
```
%LOCALAPPDATA%\agent-manager\
├── agents\ installations
├── logs\ logs agents
└── state\ state.json, events-*.jsonl, probe-cache.json
```
### Linux
```
~/.local/share/agent-manager/ données
~/.local/state/agent-manager/ logs, state, events, cache
~/.config/agent-manager/ config.yaml
```
### macOS
```
~/Library/Application Support/agent-manager/ données
~/.local/state/agent-manager/ logs, state, events, cache
~/.config/agent-manager/ config.yaml
```
Variables d'environnement de débogage/test :
- `AGENT_MANAGER_DATA` : remplace le répertoire de données
- `AGENT_MANAGER_STATE` : remplace le répertoire d'état
---
## 🔐 Sécurité
- Mode `--dry-run` universel pour simuler sans modifier
- Checksums `sha256` pour les binaires téléchargés
- Confirmation avant exécution des scripts d'installation
- Secrets dans le trousseau OS, jamais dans les logs ni le journal
- Installation isolée : aucune modification système
---
## 🔁 Résumé du flux de données
```mermaid
flowchart LR
CONFIG["📄 YAML config"] --> CATALOG["📚 Catalog"]
CATALOG --> INSTALL["📦 Installateurs"]
INSTALL --> STATE["💾 state.json"]
STATE --> PROCESS["⚙️ Processus"]
PROCESS --> EVENTS["📝 Journal JSONL"]
EVENTS --> STATS["📊 Stats / Dashboard"]
EVENTS --> SESSIONS["📅 Sessions"]
EVENTS --> PROJECTS["🗂️️ Projets"]
```
---
## ⚡ Shell AI
`am ai` (alias `am shell`) est une commande dédiée aux **actions shell via langage naturel**. Elle repose sur un agent léger (par défaut **AIChat**, installé comme n'importe quel autre agent via le catalogue) et réutilise le registre de providers LLM (`providers.rs`) pour choisir le modèle le plus rapide/cheap.
### Flux d'exécution
```mermaid
sequenceDiagram
autonumber
participant User
participant cli as cli.rs
participant shell_ai as shell_ai.rs
participant catalog as catalog.rs
participant providers as providers.rs
participant runner as runner.rs
participant aichat as aichat (agent)
User->>cli: am ai "traite les JSON"
cli->>shell_ai: parse args (--exec, --files)
shell_ai->>catalog: agent "aichat" installé ?
catalog-->>shell_ai: Ok / install
shell_ai->>providers: provider & modèle par défaut
providers-->>shell_ai: config (ollama / cheap cloud)
shell_ai->>shell_ai: injecte cwd + fichiers (--files)
shell_ai->>runner: exec aichat -f . -e "..."
runner->>aichat: lancement processus
aichat-->>runner: commande générée / exécutée
runner-->>shell_ai: output + exit code
shell_ai->>shell_ai: journalise événement shell_ai
shell_ai-->>User: résultat ou confirmation
```
### Sécurité
| Règle | Détail |
|---|---|
| `--dry-run` par défaut | Aucune commande modifiante n'est exécutée sans confirmation |
| Classification risk/certainty | Inspiré d'AI CLI : chaque commande est classée `safe` ou `risky` |
| Confirmation utilisateur | Les commandes `risky` demandent une validation explicite |
| Mode local possible | Support d'Ollama via `providers.rs` pour ne pas sortir les données |
### Dépendances
- `src/shell_ai.rs` : parsing du prompt, gestion des flags, appel à l'agent
- `src/providers.rs` : résolution du provider/modèle
- `src/runner.rs` : exécution du binaire `aichat`
- `src/events.rs` : journalisation `shell_ai` dans le journal JSONL
- `config.yaml` : définition de l'agent `aichat` + alias `ai`
---
## 📚 Références
- `src/lib.rs:4` : documentation d'architecture du crate
- `src/cli.rs:24` : options globales
- `src/cli.rs:61` : énumération des commandes
- `src/app.rs:16` : struct `App`
- `src/config.rs:25` : struct `Config`
- `src/config.rs:148` : struct `AgentDef`
- `src/config.rs:241` : enum `InstallType`
- `src/state.rs:12` : struct `StateFile`
- `src/events.rs:57` : struct `Event`
- `README.md` : documentation utilisateur complète
---
*Document généré pour agent-manager v0.4.3* 🚀
Generated
+314 -83
View File
@@ -21,9 +21,10 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.4.1"
version = "1.1.9"
dependencies = [
"anyhow",
"base64",
"chrono",
"clap",
"clap_complete",
@@ -32,16 +33,21 @@ dependencies = [
"crossterm",
"flate2",
"keyring",
"nu-ansi-term",
"ratatui",
"rustyline",
"reedline",
"rpassword",
"semver",
"serde",
"serde_json",
"serde_yaml",
"sha1",
"sha2",
"shell-words",
"tar",
"tempfile",
"tiny_http",
"toml",
"ureq",
"wait-timeout",
"which",
@@ -138,6 +144,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "atomic"
version = "0.6.1"
@@ -191,6 +203,9 @@ name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "block-buffer"
@@ -272,12 +287,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
@@ -298,6 +307,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -368,15 +383,6 @@ dependencies = [
"roff",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -471,6 +477,7 @@ dependencies = [
"mio",
"parking_lot",
"rustix",
"serde",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -632,12 +639,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "euclid"
version = "0.22.14"
@@ -855,6 +856,12 @@ dependencies = [
"digest",
]
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -1036,6 +1043,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1089,7 +1105,10 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"linux-keyutils",
"log",
"windows-sys 0.60.2",
"zeroize",
]
@@ -1126,6 +1145,16 @@ dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "linux-keyutils"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags 2.13.1",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1174,7 +1203,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix 0.29.0",
"nix",
"winapi",
]
@@ -1227,18 +1256,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -1247,7 +1264,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases 0.2.2",
"cfg_aliases",
"libc",
"memoffset",
]
@@ -1262,6 +1279,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -1601,16 +1627,16 @@ dependencies = [
"compact_str",
"critical-section",
"hashbrown 0.17.1",
"itertools",
"itertools 0.14.0",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"strum 0.28.0",
"thiserror 2.0.20",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -1666,14 +1692,14 @@ dependencies = [
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"strum 0.28.0",
"time",
"unicode-segmentation",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -1685,6 +1711,26 @@ dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "reedline"
version = "0.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "826c1fc22a2b1f14c3f6a80fc3d56adfbfb913d07f170bce4246700de7adfd50"
dependencies = [
"chrono",
"crossterm",
"fd-lock",
"itertools 0.13.0",
"nu-ansi-term",
"serde",
"strip-ansi-escapes",
"strum 0.27.2",
"thiserror 2.0.20",
"unicase",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -1734,6 +1780,27 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1797,26 +1864,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rustyline"
version = "14.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"clipboard-win",
"fd-lock",
"libc",
"log",
"memchr",
"nix 0.28.0",
"unicode-segmentation",
"unicode-width 0.1.14",
"utf8parse",
"windows-sys 0.52.0",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1878,6 +1925,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -1992,19 +2048,49 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strip-ansi-escapes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros 0.27.2",
]
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
"strum_macros 0.28.0",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -2155,7 +2241,7 @@ dependencies = [
"libc",
"log",
"memmem",
"nix 0.29.0",
"nix",
"num-derive",
"num-traits",
"ordered-float",
@@ -2240,6 +2326,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -2250,6 +2348,47 @@ dependencies = [
"zerovec",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2262,6 +2401,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2280,17 +2425,11 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -2366,6 +2505,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]]
name = "vtparse"
version = "0.6.2"
@@ -2633,7 +2781,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -2642,7 +2790,16 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
@@ -2660,14 +2817,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
@@ -2676,48 +2850,105 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winsafe"
version = "0.0.19"
+9 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.4.1"
version = "1.1.9"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -25,14 +25,20 @@ semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
rustyline = { version = "14", default-features = false, features = ["with-file-history"] }
reedline = "0.49"
nu-ansi-term = "0.50"
sha2 = "0.10"
shell-words = "1"
tar = "0.4"
tempfile = "3"
tiny_http = "0.12"
sha1 = "0.10"
base64 = "0.22"
toml = "0.8"
ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2"
keyring = "3"
keyring = { version = "3", features = ["windows-native", "linux-native"] }
rpassword = "7"
which = "7"
zip = "0.6"
crossterm = "0.29"
+110 -7
View File
@@ -1,7 +1,7 @@
# 🚀 agent-manager — vos agents IA, gérés comme des apps
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **72 agents connus**)
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **78 agents connus**)
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
Go, Rust…) prise en charge pour vous.
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
## ✨ Pourquoi am ?
- 🗂️ **Catalogue de 72 agents prêts à l'emploi** — descriptions, méthodes
- 🗂️ **Catalogue de 78 agents prêts à l'emploi** — descriptions, méthodes
d'installation, dépendances et commandes de démarrage déjà configurées.
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
@@ -48,6 +48,32 @@ plateforme, il compile automatiquement depuis les sources.
> 🔄 **Mise à jour** : relancez simplement la même commande.
### 🚀 Premier lancement (onboarding v1.1.0)
Au premier lancement (ou dès que vous tapez `am ai` / `am ask` sans
provider configuré), `am` vous propose le wizard d'onboarding :
am setup
Il vous guide en 3 étapes :
1. **Provider** : anthropic, openai, deepseek, google, ollama (local) ou
custom (base URL).
2. **Token API** : saisie masquée, stockée dans le trousseau OS (keyring)
— jamais écrite en clair dans la config.
3. **Modèle par défaut** : choisi parmi les modèles déclarés du provider.
Le wizard installe ensuite **aichat** (le moteur de `am ai`) si vous
acceptez, et génère **6 rôles copilot** pour aichat (`am setup --roles`
pour les régénérer) :
| Rôle | Rôle |
|---|---|
| `am-copilot` — guide & catalogue am | `am-dev` — code, git, tests |
| `am-operator` — shell sécurisé | `am-analyst` — coûts, logs, stats |
| `am-do` — exécution pure pour `--exec` | `am-orchestrator` — groupes, lab |
Utilisation : `am ai --role am-operator "compresse les fichiers JSON"`.
---
## 🎬 Vos 3 premières commandes
@@ -77,8 +103,30 @@ plateforme, il compile automatiquement depuis les sources.
| am list | agents installés (gérés + détectés sur le PATH) |
| am list --all | tout le catalogue, avec l'état de chacun |
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
| am ask "<demande>" | langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (settings.ask), confirmation avant exécution — ex: am ask "installe claude et lance-le" |
| am ai "<demande>" [--exec] [--files <path>] [--role <rôle>] | langage naturel → action shell via AIChat (alias: am shell) : conversationnel par défaut ; --exec génère la commande, la classe safe/risky et applique la politique de sécurité (settings.shell_ai, dry-run par défaut — --yes pour exécuter) ; --role = rôle copilot am-* (am setup) |
| am setup [--roles] | wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat + rôles copilot (v1.1.0) |
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs |
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
| am providers list / add / set-token | registre LLM centralisé : base_url, modèles par provider, clé dans le trousseau (jamais en clair, résolue via @secret) |
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
| am registry publish/search/install | registre communautaire de catalogues (Gitea) : publication, recherche, installation avec checksum sha256 vérifié et décision de confiance explicite |
| am audit | qui a modifié quoi, quand (checksums config + journal) |
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
| am doctor --watch <s> | vérifications périodiques de l'environnement + alerte en cas de panne |
| am monitor [--json] | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils |
| am stats --costs | coût estimé par agent (tokens in/out, $) — modèles de prix configurables |
| am sync [--message <m>] | sauvegarde git de l'état (state.json, journal, historique) — secrets exclus |
| am migrate export/import | bundle de transfert machine A → B (config + état + historique + backups) |
| am service install <agent> --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto |
| am schedule add <cmd...> --at HH:MM | planifie une commande am (ex: update --all) ; list / remove / run |
| am start group:dev --parallel | orchestration de groupes : ordre, --parallel, attente de santé (healthcheck) |
| am run <agent> --container | exécute l'agent dans son profil conteneur (docker/podman) |
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
@@ -96,6 +144,48 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| 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 |
### 📁 Où et comment agent-manager installe et gère les agents
`am` ne touche jamais au système : chaque agent est installé dans un **répertoire utilisateur isolé**, et ses exécutables (ou shims) sont centralisés dans un `bin/` commun que `am` met automatiquement sur le `PATH` au lancement.
| Plateforme | Répertoire de l'agent | Dossier `bin` partagé | Fichier d'état | Configuration utilisateur |
|---|---|---|---|---|
| Linux | `~/.local/share/agent-manager/agents/<agent>/` | `~/.local/share/agent-manager/bin/` | `~/.local/state/agent-manager/state.json` | `~/.config/agent-manager/config.yaml` |
| macOS | `~/Library/Application Support/agent-manager/agents/<agent>/` | `~/Library/Application Support/agent-manager/bin/` | `~/Library/Application Support/agent-manager/state.json` | `~/.config/agent-manager/config.yaml` |
| Windows | `%LOCALAPPDATA%\agent-manager\agents\<agent>\` | `%LOCALAPPDATA%\agent-manager\bin\` | `%LOCALAPPDATA%\agent-manager\state.json` | `%APPDATA%\agent-manager\config.yaml` |
Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AGENT_MANAGER_CONFIG_DIR` peuvent déplacer ces emplacements. Le répertoire d'installation est aussi configurable via `settings.install_dir` et celui des logs via `settings.log_dir`.
#### Cycle d'installation (`am install <agent>`)
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (78 agents) avec `~/.config/agent-manager/config.yaml` et un éventuel `./agent-manager.yaml`. Vos définitions remplacent les entrées par défaut du même nom.
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place.
3. **Choix de la méthode** — chaque agent déclare une méthode principale et des alternatives. `--method <nom>` force le choix ; sinon `am` prend la première disponible. Pour `am update`, c'est la méthode utilisée lors de l'installation initiale qui est reconstruite.
4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
| Méthode | Ce que `am` exécute localement | Où ça atterrit |
|---|---|---|
| `npm` | `npm install -g --prefix <root> <package>` | binaires dans `<root>/bin/`, liens ou copie dans `bin/` |
| `bun` | `BUN_INSTALL=<root> bun install -g <package>` | binaires dans `<root>/bin/`, shims dans `bin/` |
| `pip` | `python -m venv <root>/venv` puis `<venv>/bin/pip install --upgrade <package>` | exécutables dans `<root>/venv/bin/` |
| `uv` | `uv venv --python 3.13 <root>/venv` puis `uv pip install --python <venv>/bin/python <package>` | exécutables dans `<root>/venv/bin/` |
| `cargo` | `cargo install --root <root> <crate>` | binaires dans `<root>/bin/` |
| `go` | `GOBIN=<bindir> go install <module>@latest` | binaire directement dans `bin/` |
| `curl` | télécharge le script, l'affiche en `--verbose`, puis l'exécute avec `sh <script>` dans `<root>/` | le script gère son propre déploiement ; `am` repère les binaires résultants |
| `binary` | télécharge un asset release (`url` ou `repo`), vérifie le `sha256` si fourni, extrait dans `<root>/dist/`, copie dans `bin/` | binaire unique dans `bin/` |
| `git` | `git clone --depth 1 <repo> <root>/repo`, exécute les commandes `build`, puis copie `binary_path` dans `bin/` ou crée un shim | `bin/<agent>` ou shim |
5. **Post-installation** — `am` exécute les commandes `post_install` déclarées dans la fiche, avec `<bindir>` en tête du `PATH`.
6. **Détection de version** — `am` lance `<binaire> --version` (puis `-V`) pour enregistrer la version réelle.
7. **Enregistrement** — une entrée est écrite dans `state.json` (`name`, `version`, `method`, `install_dir`, `bins`, `run`, `installed_at`, `pid`, etc.).
#### Cycle de gestion (`am start / stop / update / uninstall`)
- **Démarrage** — `am start <agent>` lit `state.json` pour retrouver le binaire et la commande `run` configurée. En arrière-plan (`--background`), le PID est enregistré dans `state.json` et la sortie est redirigée vers `<log_dir>/<agent>.log`.
- **Arrêt** — `am stop` envoie `SIGTERM`, attend `settings.stop_timeout_secs` (5 s par défaut), puis `SIGKILL` si besoin. Le PID est effacé de `state.json`.
- **Mise à jour** — `am update <agent>` détermine la dernière version disponible (registre npm/PyPI/cargo/release), crée une sauvegarde (`am update --rollback`), réinstalle avec la même méthode et met à jour `state.json`. Les versions épinglées (`pin_version`) sont respectées.
- **Désinstallation** — `am uninstall <agent>` arrête le processus, supprime `<root>/`, supprime les binaires de `bin/` et efface l'entrée de `state.json`. `--purge` efface aussi les logs et retire la définition du fichier de config utilisateur.
### 🚀 Exécuter
| Commande | Rôle |
@@ -118,8 +208,12 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am projects [nom] | quel agent travaille sur quel projet (agent dominant, sessions, durées) |
| am sessions [agent] --status --show --resume | registre des sessions + reprise d'une session arrêtée |
| am history --search --failed --rerun N | historique des commandes (am + shell), filtrable et réexécutable |
| am history 12..25 --save <f> · am playbook <f> | exporte une plage en playbook YAML et la rejoue pas à pas ({{var}}) |
| am lab --agents a,b --task <f> [--parallel] [--json] | benchmark : même tâche sur plusieurs agents (durée, exit, coût) — tâches versionnables dans <state>/lab/ |
| am plugins [--test <nom>] | scripts d'extension sur les événements (on_install/on_start/on_stop/on_update), contrat JSON stdin/stdout, timeout — exemples dans examples/plugins/ |
| am logs <agent> --follow | tail du log d'un agent, en direct |
| am dashboard | tableau de bord TUI temps réel : vue d'ensemble, activité, stats, sessions, projets (Tab/←/→ : onglets, j/k : défilement, q : quitter) |
| am web [--port <p>] | dashboard web local en lecture seule (127.0.0.1) : vue d'ensemble, graphiques, sessions, événements, projets — API JSON /api/* avec les mêmes contrats que les sorties --json |
### 🎯 Personnaliser
@@ -144,6 +238,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am config show / edit / add / set | gère votre configuration (hooks on_start/on_stop/… dans settings.hooks) |
| am open <agent> | ouvre le répertoire d'installation dans l'explorateur |
| am completion <shell> [--installed] | script de complétion (bash, zsh, fish, powershell, elvish) ; --installed complète dynamiquement les agents installés, alias et groupes |
| am serve --token [--port <p>] | API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask…) — rate limiting par IP, TLS via reverse proxy |
| am self-update | met à jour am lui-même (si configuré) |
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
| am | shell interactif : bannière, passerelle système, Tab et historique |
@@ -190,6 +285,7 @@ Disponibles avant **ou après** la sous-commande.
| -v, --verbose | affiche chaque commande exécutée |
| -q, --quiet | uniquement les erreurs |
| --no-color | désactive les couleurs |
| --lang <fr\|en> | langue des messages : fr (défaut) ou en — AM_LANG/LANG lus sinon |
| -c, --config <fichier> | configuration alternative |
---
@@ -202,6 +298,7 @@ Disponibles avant **ou après** la sous-commande.
am start cc --profile dev # profil d'environnement : env + args injectés
am dashboard # TUI temps réel : toute l'activité d'un coup d'œil
am web # dashboard web local (ouvre le navigateur) : http://127.0.0.1:7878/
am favorite cc && am tag cc python # personnalisez votre parc
am search --tag python # retrouvez les agents que vous avez tagués
@@ -209,6 +306,7 @@ Disponibles avant **ou après** la sous-commande.
❯ inst<Tab> # complétion Tab : commandes, agents installés, groupes, options
❯ install jcode
❯ dashboard # le dashboard se lance aussi depuis le REPL
❯ web # ... et le dashboard web aussi (am web --port pour changer le port)
❯ exit # historique persistant (flèches haut/bas)
### 🐚 Passerelle système
@@ -227,16 +325,21 @@ PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
❯ 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
❯ cd <Tab> # complétion des dossiers — menu interactif façon Nushell
❯ 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.
La complétion Tab est un **menu interactif façon Nushell** : une
correspondance unique est insérée directement ; plusieurs correspondances
ouvrent un menu sous la ligne avec le **premier choix en surbrillance**, et
chaque Tab déplace la surbrillance vers le choix suivant (Shift+Tab vers
l'arrière). **Entrée** valide le choix, **Échap** ferme le menu, les flèches
↑/↓ naviguent et continuer à taper filtre la liste en direct. Le menu
affiche une **description** à côté de chaque candidat (commandes et agents)
et suit la palette du thème actif.
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
`config.yaml` (voir `am config path`).
@@ -280,7 +383,7 @@ supprimez aussi ces emplacements.)
1. --config <fichier>
2. ./agent-manager.yaml (répertoire courant)
3. ~/.config/agent-manager/config.yaml
4. catalogue embarqué (72 agents)
4. catalogue embarqué (78 agents)
Vos définitions **complètent ou surchargent** le catalogue par nom.
+17 -11
View File
@@ -30,14 +30,20 @@ Cargo.lock, et lancer toute la batterie :
### y compris Debian 12 (glibc 2.36) — ne JAMAIS publier un binaire glibc
### compilé sur une base récente.
docker run --rm -v "${PWD}:/work" -w /work -e CARGO_TARGET_DIR=/tmp/target-musl \
rust:1.94-alpine sh -c "apk add --no-cache build-base && \
# cargo-zigbuild : zig (~50 Mo) fournit le linker croisé pour les DEUX
# cibles musl (rust:1.94-alpine n'embarque pas de compilateur croisé
# aarch64 : 'aarch64-linux-musl-gcc not found').
docker run --rm -v "${PWD}:/work" -w /work -e CARGO_TARGET_DIR=/tmp/am-target \
-e CARGO_INCREMENTAL=0 rust:1.94-alpine sh -c "apk add --no-cache build-base zip curl >/dev/null && \
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl && \
cargo build --release --locked --target x86_64-unknown-linux-musl && \
cargo build --release --locked --target aarch64-unknown-linux-musl && \
curl -fsSL https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz -o /tmp/zig.tar.xz && \
tar -xf /tmp/zig.tar.xz -C /opt && \
export PATH=/opt/zig-x86_64-linux-0.14.1:\$PATH && \
cargo install cargo-zigbuild --locked && \
cargo zigbuild --release --locked --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl && \
mkdir -p dist && \
cp /tmp/target-musl/x86_64-unknown-linux-musl/release/am dist/am-linux-x86_64 && \
cp /tmp/target-musl/aarch64-unknown-linux-musl/release/am dist/am-linux-aarch64 && \
cp /tmp/am-target/x86_64-unknown-linux-musl/release/am dist/am-linux-x86_64 && \
cp /tmp/am-target/aarch64-unknown-linux-musl/release/am dist/am-linux-aarch64 && \
cd dist && tar -czf am-linux-x86_64.tar.gz am-linux-x86_64 && \
tar -czf am-linux-aarch64.tar.gz am-linux-aarch64"
@@ -56,7 +62,7 @@ doit signaler aucune dépendance manquante.
Une fois toutes les archives dans `dist/` :
scripts/render-manifests.ps1 -Version 0.4.1
scripts/render-manifests.ps1 -Version 0.4.3
Ce script rend, avec la version et les sha256 réels :
@@ -67,16 +73,16 @@ Ce script rend, avec la version et les sha256 réels :
- `dist/homebrew/am.rb` → à servir via un tap, ou à soumettre
- `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz
dans ~/rpmbuild/SOURCES)
- Paquet deb : `packaging/deb/make-deb.sh 0.4.1` → dist/am_0.4.1_amd64.deb
- Paquet deb : `packaging/deb/make-deb.sh 0.4.3` → dist/am_0.4.3_amd64.deb
## 4. Créer la release sur Gitea
Via l'interface web : Releases > New Release, tag v0.4.1, attacher les
Via l'interface web : Releases > New Release, tag v0.4.3, attacher les
archives. Ou via l'API (jeton Gitea requis) :
curl -X POST -H "Authorization: token <JETON>" \
-H "Content-Type: application/json" \
-d '{"tag_name":"v0.4.1","name":"v0.4.1","body":"notes de version"}' \
-d '{"tag_name":"v0.4.3","name":"v0.4.3","body":"notes de version"}' \
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases
curl -X POST -H "Authorization: token <JETON>" \
@@ -89,7 +95,7 @@ archives. Ou via l'API (jeton Gitea requis) :
## 5. Tag git (déclenche aussi le pipeline CI)
git tag v0.4.1 && git push origin v0.4.1
git tag v0.4.3 && git push origin v0.4.3
## Conventions de nommage (attendues par les installateurs et self-update)
+423 -50
View File
@@ -1,6 +1,13 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.2** · Statut : propositions, non engagées
> **Version du document : 3.1** · Statut : phases 0 à 3 livrées
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.5
>
> 🧭 **Bilan du 2026-08-23** : tout le périmètre versionné (J0 → J3, v0.7.0,
> épique v1.1.0) est livré — **0 issue ouverte** sur Gitea. Il reste :
> **1 bug prioritaire** (hang de `cargo test` / `am doctor`, §15.4),
> 3 fonctionnalités P1 jamais découpées en issues (§15.2), des activités de
> release (§15.3) et des idées neuves proposées (§16).
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
>
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
@@ -13,6 +20,141 @@
> profils d'environnement (#40), les complétions dynamiques + man pages
> (#41) et le packaging officiel + CI (#42). Binaires Windows/Linux publiés
> sur Gitea — am self-update et les installateurs servent la v0.4.1.
>
> ✅ **Phase 2 livrée en v0.6.0 (2026-08-19)** : 28/28 issues clôturées
> (#47–#75), 250+ tests. am web, i18n, services, schedule, lab, sync,
> migrate, models, plugins d'événements… Binaires publiés sur Gitea.
>
> ✅ **Phase v0.7.0 livrée (2026-08-19)** : 5/5 issues clôturées (#88–#92) —
> registre de providers LLM (am providers), secrets partagés par provider,
> flags --provider/--model/--no-config à l'install, configuration
> post-install (bloc config:).
>
> ✅ **Phase 3 livrée en v1.0.0 (2026-08-20)** : 5/5 issues clôturées
> (#76–#80) — télémétrie opt-in, am registry, am ask, profils sandbox,
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
> archives, prompt REPL avec shell actif.
>
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect`
> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python),
> plus de faux « external » ; docs alignées.
>
> 🔧 **Maintenance v1.0.4 (2026-08-20)** : catalogue enrichi — 5 agents
> **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub
> Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI
> (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents.
>
> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée**
> (issues #96 #97) — langage naturel → action shell via AIChat : mode
> conversationnel (aichat -f <ctx>), mode --exec avec pipeline de sécurité
> (génération aichat --dry-run, classification safe/risky + certainité,
> politique dry-run par défaut configurable settings.shell_ai, confirmation
> y/N pour les commandes risky, exécution via le shell de l'utilisateur),
> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements
> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée.
>
> 🔧 **Maintenance v1.0.6 (2026-08-20)** : installateur Windows corrigé —
> `install.ps1` n'acceptait que l'entrée `am.exe` dans l'archive de release
> (la CI et les archives manuelles utilisent `am-windows-x86_64.exe`) →
> repli systématique sur `cargo install` au lieu du binaire précompilé.
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
> les deux entrées pour la compatibilité maximale. Chemin du message
> cargo install corrigé (`.cargo\bin\am.exe`).
>
> 🚀 **Épique v1.1.0 (2026-08-20)** : **Onboarding, Copilot & Rôles aichat**
> (Axe 14) — `am setup` (wizard provider + token keyring + modèle + ping),
> installation automatique d'aichat (wizard + `am ai` + installateurs),
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
>
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
> validateur : « unknown field `providers` »). Le registre vivant sous
> `settings.providers`, le wizard écrit désormais
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
> base_url quand un bloc provider est écrit). Test de régression
> `setup_persists_under_settings_providers`.
>
> 🔧 **Maintenance v1.1.2 (2026-08-20)** :
> - `am setup` : **openrouter** ajouté au choix des providers (base URL,
> token via OPENROUTER_API_KEY, 6 modèles déclarés proposés) ;
> le choix du modèle liste les modèles disponibles du provider.
> - `am ai` sans `--role` utilise **am-copilot par défaut** (régénération
> automatique des rôles si le fichier manque, dégradation douce).
> - Bug `am migrate --import` : la config importée était écrite dans le
> répertoire config GLOBAL (user_config_dir) au lieu du config actif de
> l'app — l'import écrasait la config réelle de la machine (et la suite
> de tests détruisait la config utilisateur à chaque `cargo test`).
> L'import respecte désormais `app.paths.config_dir` (--config/isolated).
>
> 🔧 **Maintenance v1.1.3 (2026-08-21)** : fix `resolve_exec` — pour un
> agent INSTALLÉ avec un binaire enregistré, le premier token de `run:`
> (ex. `picoclaw`) n'était jamais consommé : `am start <agent>` lançait
> `<bin> <token-run>` (picoclaw rejetait son propre nom comme
> sous-commande inconnue ; aichat recevait « aichat » comme prompt
> one-shot). Le token est désormais consommé dans la branche « binaire
> enregistré existant » (les branches non-installé/binaire manquant
> étaient déjà correctes). Test de régression
> `resolve_exec_installed_agent_does_not_leak_run_token`.
>
> 🔧 **Maintenance v1.1.4 (2026-08-21)** : hints de configuration
> post-install — nouveau champ `setup_hints` dans la définition d'un agent
> (config.yaml) : commandes de configuration manuelle affichées après
> l'install quand l'agent gère sa propre config (ex. picoclaw
> onboard/model/auth), avec substitution des placeholders `{model}`,
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
> premier agent du catalogue à les déclarer.
>
> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec
> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown
> `agents/<name>.md` ≤ 0.29 est remplacé par une définition
> `functions/agents/<name>/index.yaml` + un registre `functions/agents.txt`).
> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats
> (compatibilité totale) et `am ai` détecte la définition nouvelle génération
> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte
> par défaut (dossier courant) est filtré par am (`.git`, `target`,
> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB)
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
> n'appelle plus l'API, il écho la requête).
>
> 🔧 **Maintenance v1.1.6 (2026-08-24)** : correctifs de robustesse —
> timeout de 30 s sur les commandes capturées (doctor/runner ne pendent plus
> si un agent ne répond pas, pipes vidés dans des threads pour éviter les
> deadlocks), `is_running` Windows via l'API Win32 (OpenProcess /
> GetExitCodeProcess, plus fiable que tasklist), rejet des chemins unsafe
> (path traversal) dans `am migrate --import`, frames WebSocket RFC 6455
> avec vraies longueurs > 125 octets, boucle de traduction i18n de la page
> web corrigée (plus de boucle infinie), index du trousseau (keyring)
> sérialisé en JSON (clés contenant des virgules).
>
> 🔧 **Maintenance v1.1.7 (2026-09-01)** : Gemini CLI a cessé de servir les
> comptes Google gratuits/Pro/Ultra le 2026-06-18 — remplacé par **Antigravity
> CLI** (`agy`, binaire Go fermé, même harnais qu'Antigravity 2.0). Nouveau
> catalogue : entrée `antigravity-cli` (release GitHub
> google-antigravity/antigravity-cli), `gemini-cli` marqué déprécié
> (`installable: false`, clé API payante seulement), alias `gemini`/`agy`
> redirigés, groupe `mainstream` et gabarit web mis à jour. Import des
> extensions/skills Gemini : `agy plugin import gemini`.
>
> 🔧 **Maintenance v1.1.8 (2026-09-01)** : providers **QwenCloud** ajoutés au
> registre par défaut — `qwen-cloud` (pay-as-you-go, Model Studio/DashScope,
> dashscope-intl…/compatible-mode/v1) et `qwen-cloud-token-plan` (abonnement
> Credits, token-plan.ap-southeast-1…/compatible-mode/v1, modèles restreints
> dont qwen3.8-max). `qwen-code` pointe sur qwen-cloud par défaut (env_map
> OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer
> sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via
> `am providers set-token`).
>
> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses
> providers dans une constante codée en dur — les QwenCloud ajoutés au
> registre n'y apparaissaient pas. La liste est maintenant construite depuis
> le registre fusionné (embarqué + config utilisateur), ollama et custom en
> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne
> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion
> `--yes`).
---
@@ -29,12 +171,14 @@
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)
12. [Bilan & reste à faire (2026-08-23)](#15--bilan--reste-à-faire-analyse-du-2026-08-23)
13. [Idées neuves (post-v1.1.5)](#16--idées-neuves-post-v115)
---
## 1. 📌 Résumé exécutif
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 77 agents au
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
@@ -96,7 +240,7 @@ quelques jours, à caler avant ou pendant la construction du journal.*
| Brique actuelle | Fichier | Limite aujourd'hui |
|---|---|---|
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
| Catalogue 77 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 |
@@ -169,8 +313,8 @@ taux de succès** — et **combien ça coûte**.
| 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 |
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) ✅ #49 | M | P2 |
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes ✅ #50 | L | P2 |
| Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
am stats # vue globale
@@ -188,7 +332,7 @@ taux de succès** — et **combien ça coûte**.
| 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 |
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml ✅ #51 | L | P2 |
am history --search "install" --failed
am history --rerun 42
@@ -245,11 +389,11 @@ consultable, reprenable, archivable.
|---|---|---|
| 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 |
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart ✅ #55 | L | P2 |
| Planification : am schedule add update --all --at 06:30 + check santé (cron / Task Scheduler) ✅ #56 | M | P2 |
| Orchestration de groupes : ordre, --parallel, attente de santé ✅ #57 | L | P2 |
| Conteneurs : profil docker/podman par agent (isolation à la demande) ✅ #58 | L | P2 |
| am doctor --watch — vérifications périodiques avec alertes ✅ #59 | M | P2 |
### Axe 7 — 🧠 Intelligence & catalogue
@@ -258,8 +402,8 @@ consultable, reprenable, archivable.
| 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 |
| ⬜ am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) — **non livré, sans issue** | S | P1 |
| ⬜ am news — dernières releases des agents installés (API GitHub/Gitea, cache) — **non livré, sans issue** | 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 |
@@ -284,9 +428,9 @@ consultable, reprenable, archivable.
| Fonctionnalité | Effort | Phase |
|---|---|---|
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 |
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL ✅ #66 | L | P2 |
| Partage de catalogue d'équipe : include par URL | S | P2 |
| am migrate — assistant de transfert machine A → B | M | P2 |
| am migrate — assistant de transfert machine A → B ✅ #68 | M | P2 |
| am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
### Axe 10 — 🧩 Confort & personnalisation
@@ -298,7 +442,7 @@ consultable, reprenable, archivable.
| 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 |
| ⬜ Raccourcis de commandes : settings.shortcuts (i → install, s → start) — **non livré, sans issue** | S | P1 |
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
| Thèmes couleurs du REPL | S | P2 |
@@ -328,6 +472,206 @@ consultable, reprenable, archivable.
---
### Axe 13 — ⚡ Shell AI & Action ⭐
🎯 Transformer une commande en langage naturel en une action shell exécutée par un agent léger, rapide et contextuel, sans démarrer un gros coding agent.
**Contexte :** `am` gère aujourd'hui ~77 agents IA, principalement des *coding agents*, des assistants et des outils SaaS. Les shell assistants sont maintenant au catalogue (AIChat, ShellGPT, Fabric, Shell AI, AI CLI — v1.0.4) : légers, rapides, conçus pour transformer une phrase en commande ou action sur le système de fichiers local (ex. *"traite les fichiers JSON du dossier courant pour extraire les clés uniques"*).
---
#### 13.1 Agents shell AI découverts (✅ tous ajoutés au catalogue — v1.0.4, issues #94 #95)
| Outil | Repo | Langage | Force | Maturité |
|---|---|---|---|---|
| ✅ **AIChat** | `sigoden/aichat` | Rust | Shell assistant natif, RAG, agents, fichiers/répertoires, 20+ providers | **10.4k stars**, très actif |
| ✅ **ShellGPT** | `TheR1D/shell_gpt` | Python | Génère/exécute commandes shell, code, docs | Très connu, mature |
| ✅ **Fabric** | `danielmiessler/fabric` | Go | Patterns AI (summarize, extract wisdom), CLI | Très populaire, orienté contenu/texte |
| ✅ **Shell AI** | `nishant9083/shell-ai` | TypeScript | Agent ReAct local via Ollama, MCP, filesystem tools | Plus récent, prometteur mais jeune |
| ✅ **AI CLI** | `kriserickson/ai-cli` | Go | Natural language → commandes shell avec safety policy (`risk` / `certainty`) | Petit, simple, moins connu |
---
#### 13.2 Recommandation : AIChat (`sigoden/aichat`)
**Choix privilégié pour la fonction Shell AI.**
| Avantage | Détails |
|---|---|
| **Léger & rapide** | Rust, binaire unique, cold start rapide, idéal pour RPi 4 |
| **Shell Assistant natif** | Génère et exécute des commandes shell à partir du langage naturel |
| **Multi-provider** | OpenAI, Claude, Gemini, DeepSeek, Groq, Ollama, OpenRouter… |
| **Passage de contexte** | `aichat -f .` injecte le dossier ou les fichiers dans le prompt |
| **Mode exécution** | `aichat -e "..."` exécute directement, mode conversationnel sinon |
| **Facilité d'intégration** | Release binaire GitHub, installable via `binary` dans le catalogue |
Exemples d'usage ciblés :
```bash
# Lister et traiter les fichiers JSON du dossier courant
aichat -f . -e "liste tous les fichiers JSON et extrait les clés uniques"
# Résumer un dossier de fichiers
aichat -f . "résume les données de ces fichiers JSON"
```
---
#### 13.3 Fonctionnalités prévues
| Fonctionnalité | Description | Effort | Phase |
|---|---|---|---|
| ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 |
| ✅ **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 |
| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 |
| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 |
| **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 |
---
#### 13.4 Risques & garde-fous
| Risque | Mitigation |
|---|---|
| Exécution automatique de commandes dangereuses | `--dry-run` par défaut, confirmation obligatoire avant toute commande `risky` |
| Coût API récurrent | Modèle cheap par défaut (`gpt-4.1-mini`, `gemini-flash`) ou Ollama local |
| Fuite de données sensibles | Support du mode local Ollama, pas d'envoi hors du provider configuré |
| Installation lente sur RPi | Utiliser l'installateur `binary` GitHub Releases plutôt que `cargo` |
---
#### 13.5 Idées sœurs (à intégrer ou lier à d'autres axes)
| Commande | Description | Axe lié |
|---|---|---|
| `am summarize <fichier/dossier>` | Résume un fichier ou un dossier via AIChat/Fabric | Axe 13 |
| `am explain <commande>` | Explique une commande shell avant exécution | Axe 13 |
| `am commit` | Génère un message de commit depuis `git diff` | Axe 13 / Axe 3 |
| `am review` | Review rapide d'un diff ou d'une PR | Axe 7 |
| `am translate <fichier> --to en` | Traduit un fichier markdown/doc | Axe 13 |
| `am ask-code "..."` | Pose une question sur le codebase sans lancer un gros agent | Axe 7 |
| `am doc <dossier>` | Génère un README/doc à partir du code | Axe 13 / Axe 7 |
| `am fix` | Corrige une commande shell qui a échoué | Axe 13 |
| `am note` | Extrait des action items d'un texte/réunion | Axe 10 |
| `am search-web "..."` | Recherche web rapide et réponse synthétisée | Axe 7 |
---
## 14. 🚀 Onboarding & Copilot aichat (épique v1.1.0)
> Objectif : réduire le **Time-To-First-Value à 0** — rendre `am` opérationnel
> pour l'IA locale dès la première seconde, et faire d'aichat le copilot de
> agent-manager. Livré en **v1.1.0 (2026-08-20)**.
| Fonctionnalité | Description | Statut |
|---|---|---|
| ✅ **Wizard d'onboarding (`am setup`)** | Provider (anthropic, openai, deepseek, google, ollama, custom), token masqué → trousseau OS (keyring, jamais en clair), modèle par défaut, ping API non-bloquant. Déclenché par `am ai`/`am ask`/banner REPL quand aucun provider n'est configuré (message discret, jamais bloquant), ré-exécutable à la main (mode revoir) | ✅ v1.1.0 |
| ✅ **Installation automatique d'aichat** | Étape du wizard « Installer le moteur IA (aichat) ? [Y/n] » → `am install aichat` (catalogue) ; `am ai` sans aichat propose l'installation ; install.ps1/install.sh enchaînent sur `am setup` si stdin interactif | ✅ v1.1.0 |
| ✅ **Rôles copilot aichat (am-*)** | 6 agents générés dans `~/.config/aichat/agents/` (ou %APPDATA%\\aichat\\agents) : am-copilot (guide & catalogue), am-operator (shell sécurisé), am-dev (code & git), am-do (exécution pure pour --exec), am-analyst (coûts/logs/stats), am-orchestrator (groupes/lab). Prompts bilingues (FR par défaut), privilégient les contrats `--json` | ✅ v1.1.0 |
| ✅ **`am ai --role <nom>`** | Rôle copilot passé à aichat (`--agent`) ; validation du fichier généré ; complétion REPL des rôles am-* ; config aichat créée (provider + modèle) seulement si absente | ✅ v1.1.0 |
| ✅ **Settings dédiés** | `settings.shell_ai` (default_safety dry-run/confirm/auto, risky_patterns) documenté dans config.yaml ; module `src/setup.rs` + `src/roles.rs` | ✅ v1.1.0 |
---
## 15. 🧭 Bilan & reste à faire (analyse du 2026-08-23)
> Analyse complète du dépôt le 2026-08-23 : code (v1.1.5, commit e637c06),
> historique git, tracker Gitea, artefacts de release.
### 15.1 ✅ Ce qui est livré
- **Tout le périmètre versionné** : J0/v0.3.0, J1/v0.4.1, J2/v0.6.0,
v0.7.0 (providers), J3/v1.0.0, épique v1.1.0 (onboarding/copilot) et les
maintenances v1.0.1 → v1.1.5.
- **0 issue ouverte** sur le tracker Gitea (issues #1 → #97 clôturées).
- `main` synchronisé avec `origin/main` ; Cargo.toml/Cargo.lock en
**v1.1.5** ; suite de tests : **455 tests passés, 0 échec** le
2026-08-23 (23 suites, hors `doctor_test` qui hang — §15.4).
- Vérifié présent dans le code : favoris/notes/tags (#39), dashboard,
monitor, web, serve, lab, sync, migrate, models, sandbox, registry,
ask, ai, setup + rôles copilot, self-update, services, schedule, i18n,
packaging, complétions, man pages.
### 15.2 ⬜ Fonctionnalités du roadmap NON livrées (jamais découpées en issues)
| Fonctionnalité | Axe | Effort | Constat dans le code |
|---|---|---|---|
| `am compare a b` — tableau côte à côte | Axe 7 | S | aucune commande `Compare` dans `src/cli.rs` |
| `am news` — dernières releases des agents installés | Axe 7 | M | aucun module, 0 occurrence dans `src/` |
| `settings.shortcuts` — raccourcis REPL (i → install…) | Axe 10 | S | 0 occurrence de `shortcuts` dans `src/` |
| Extensions sœurs Axe 13 (`am summarize`, `am explain`, `am fix`, …) | Axe 13 | M | absentes — voir §13.5 et l'idée N10 (§16) |
➡️ Suggestion : créer 3 issues Gitea (compare, news, shortcuts) en P1 —
petits efforts à forte valeur, seuls restes des jalons « cockpit ».
### 15.3 📦 Activités de release / opérations
| Activité | Détail |
|---|---|
| **Publier la release v1.1.5** | Le commit `e637c06` (fix aichat ≥ 0.30) est sur `main` **sans tag ni release Gitea** → installateurs et `am self-update` servent encore la v1.1.4. Taguer `v1.1.5` pour déclencher `.gitea/workflows/release.yml`. |
| **Compléter les assets v1.1.4** | La release v1.1.4 publiée ne contient **que** `am-windows-x86_64.zip` : binaires Linux (x86_64/aarch64) et macOS absents → les installateurs Linux/macOS retombent sur `cargo install --git`. |
| **Artéfacts `dist/` obsolètes** | Les archives de `dist/` datent de l'ère v0.6.0 (2026-08-19) ; laisser la CI régénérer celles de v1.1.5. |
| **Stash obsolète** | `stash@{0}` « WIP issue #39 : favoris, notes, tags » (2026-08-17 09:12) — la fonctionnalité a été livrée autrement dans v0.4.1 (champs présents dans `src/state.rs`) ; à purger (`git stash drop`) après confirmation. |
### 15.4 🐛 Bug découvert pendant l'analyse (2026-08-23) — à corriger en priorité
**`cargo test` se suspend** sur `doctor_test::report_contains_core_checks`
sur cette machine (reproduit 3 fois, dont une exécution > 20 min), et le
même hang peut frapper l'utilisateur final via `am doctor`.
- **Cause racine** : `SystemRunner::run` (`src/runner.rs`, branche
`capture`) attend `Command::output()` **sans timeout** et sans
protection contre l'héritage des pipes. `am doctor` sonde les outils du
PATH (`ollama ls`, `llama-server --version`, `git --version`…) ; quand
le processus fils engendre un petit-enfant qui conserve les handles
stdout/stderr (cas réel observé : `conhost.exe` alloué pour
`ollama.exe ls` lancé sans console), `output()` attend l'EOF des pipes
**indéfiniment**.
- **Reproduction** : `target\debug\deps\doctor_test-*.exe --nocapture
report_contains_core_checks` → hang > 40 s avec enfants `ollama.exe ls`
+ `conhost.exe` sous le processus de test ; l'ancien binaire de test du
2026-08-21 passait en 2,2 s (déclenchement du conhost non déterministe).
- **Périmètre touché** : tout ce qui passe par `SystemRunner::run` avec
capture — `am doctor` donc, et les sondes d'installation. (`am models`,
`am lab`, les plugins et la sonde externe utilisent déjà `wait_timeout`
et sont immunisés.)
- **Correctif proposé** : aligner `SystemRunner::run` sur le pattern déjà
éprouvé dans `src/models.rs` / `src/lab.rs` / `src/plugins.rs` : spawn
+ `wait_timeout` (30 s par défaut, `settings.probe_timeout_secs`) +
drain des pipes avant l'attente + kill de l'arbre à l'expiration — les
commentaires de `lab.rs` et `plugins.rs` documentent exactement ce
deadlock. À compléter par un test de régression (faux binaire qui
engendre un enfant garde-fou).
- **Contournement immédiat** : lancer les suites une à une, sans
`--test doctor_test`.
---
## 16. 💡 Idées neuves (post-v1.1.5)
> Ajoutées lors de l'analyse du 2026-08-23. Toutes ⬜ (proposées), non
> planifiées. Respectent l'ADN du projet : zéro dépendance runtime, local
> par défaut, contrats `--json` stables.
| # | Idée | Description | Axe | Effort | Phase |
|---|---|---|---|---|---|
| N1 | ⬜ **`am mcp` — serveur MCP** | Exposer am comme serveur *Model Context Protocol* : les agents (Claude Code, Codex, aichat…) découvrent et pilotent le parc via des outils MCP (`am__list`, `am__stats`, `am__sessions`, `am__start`…). am devient le hub de coopération entre agents. | nouveau | L | P3 |
| N2 | ⬜ **Budgets & alertes de coûts** | Budget par agent/projet avec alertes à seuils (80 % / 100 %) et blocage doux, branché sur le suivi des coûts (#49). | Axe 1 | M | P2 |
| N3 | ⬜ **Webhooks de notification** | Alertes `am watch` / `schedule` / `doctor --watch` vers un webhook (Discord, Slack, n8n, Gotify) en plus du `--notify` local. | Axe 6 | S | P2 |
| N4 | ⬜ **`am test <agent>` — smoke test** | Vérification normalisée post-install/post-update : le binaire répond, ping provider, tâche minimale — généralise le ping de `am setup` à tout le catalogue. | Axe 7 | M | P2 |
| N5 | ⬜ **`am diff` — comparaison de configs** | Diff de config/état entre deux machines ou deux points de sync ; complète `am sync` (#66) et `am migrate` (#68) pour le multi-machine. | Axe 9 | S | P2 |
| N6 | ⬜ **Transcripts de session** | `am sessions transcript <id>` : export Markdown lisible et partageable d'une session (commandes + sorties), complément de `sessions export` (#53). | Axe 4 | S | P2 |
| N7 | ⬜ **`am doctor --report`** | Rapport de diagnostic complet anonymisé (versions, doctor, événements récents) prêt à joindre à un bug report. | Axe 12 | S | P2 |
| N8 | ⬜ **Catalogue air-gapped** | `am catalog snapshot` : instantané hors-ligne complet du catalogue distant (définitions + binaires) pour machines sans internet. | Axe 7 | M | P3 |
| N9 | ⬜ **Export OpenTelemetry** | Export opt-in du journal `events.jsonl` vers OTLP/Grafana pour les équipes qui ont déjà une pile d'observabilité. | Axe 1 | M | P3 |
| N10 | ⬜ **Épique « Boîte à outils IA »** | Formaliser les commandes sœurs §13.5 sur les rôles copilot aichat (v1.1.0) : `am commit`, `am review`, `am explain`, `am fix`, `am summarize`, `am doc`, `am translate`, `am ask-code`, `am search-web`, `am note`. Une commande = un rôle + un raccourci de prompt, sécurité `--exec` héritée de `am ai`. | Axe 13 | L | P2 → P3 |
| N11 | ⬜ **Suite de tests 100 % hermétique** | `update_policy_test` (> 60 s/test) appelle réellement l'API GitHub via `installers::latest_version` ; prévoir un mode hors-ligne des sondes de version (flag test/offline ou mock), pour une suite rapide et déterministe partout. | Axe 12 | S | P1 |
---
## 8. 🚀 Jalons versionnés
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
@@ -430,33 +774,33 @@ moins de 2 secondes.
| # | Issue | Effort |
|---|---|---|
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M |
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) | L |
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L |
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | Templates — am init --template web | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | am sessions export + rétention configurable | M |
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L |
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L |
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | Politiques — pin de version, settings.update_policy | S |
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | Partage de catalogue d'équipe (include par URL) | S |
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M |
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | Thèmes couleurs du REPL | S |
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | Lien agent ↔ modèle — am run --model | M |
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | doctor vérifie ollama / llama-server comme n'importe quel outil | S |
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | am models prune — purge des modèles inutilisés | S |
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L |
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | Plugin scripts (hooks avancés, intégration CI) | M |
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session (stats --costs, cost_models) ✅ | M |
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) ✅ | L |
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | ✅ Playbooks — am history --save + am playbook | L |
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M |
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M |
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | ✅ am web — serveur local + API JSON + graphiques (dashboard embarqué, contrats --json, 127.0.0.1) | XL |
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) ✅ | L |
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (add --at / list / remove / run) ✅ | M |
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) ✅ | L |
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent ✅ | L |
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes ✅ | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | ✅ am lab — benchmark : même tâche sur N agents | L |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | ✅ am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | ✅ am update --rollback — backup automatique avant mise à jour | M |
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S |
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | ✅ Synchronisation git automatique — am sync (sync_repo, sync_on_exit, secrets exclus) | L |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S |
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | ✅ am migrate — assistant de transfert machine A → B (bundle .amx + doctor) | M |
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | ✅ Thèmes couleurs du REPL | S |
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | ✅ am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | ✅ Lien agent ↔ modèle — am run --model | M |
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | ✅ doctor vérifie ollama / llama-server comme n'importe quel outil | S |
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | ✅ am models prune — purge des modèles inutilisés | S |
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | ✅ i18n — messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt) | L |
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | ✅ Plugin scripts (hooks avancés, intégration CI) | M |
Critère de sortie du jalon : parc auto-supervisé (crash = redémarrage +
alerte).
@@ -467,16 +811,45 @@ alerte).
| # | Issue | Effort |
|---|---|---|
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | ✅ Registre communautaire — am registry (publication + recherche sur Gitea) | L |
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | ✅ Profils sandbox par agent (commandes/répertoires autorisés) | L |
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | ✅ am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
Critère de sortie du jalon : dashboard web complet + API distante.
### Phase v0.7.0 — Providers & configuration automatisée (P0, PRIORITAIRE)
Épique : [issue #87](https://git.dracodev.net/Projets/agent-manager/issues/87)
· milestone v0.7.0 · 5 issues · **priorité P0 : livrée AVANT la Phase 3
(v1.0, #76–#80 en P3), sauf dépendances**.
Centralise tokens/providers/modèles dans UN registre (settings.providers +
keyring partagé) et configure automatiquement les agents à l'installation :
`am install <agent>` branche le provider/modèle par défaut,
`--provider/--model` pour surcharger, `--no-config` pour le comportement
actuel, `am run --provider` pour surcharger au lancement (cloud inclus).
S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés.
| # | Issue | Effort | Dépend de |
|---|---|---|---|
| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — |
| [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | ✅ Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 |
| [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | ✅ AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 |
| [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | ✅ Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 |
| [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | ✅ `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 |
Critère de sortie du jalon : un agent installé est opérationnel sans
configuration manuelle (tokens au keyring, provider/modèle par défaut).
---
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
La phase 0 est livrée (v0.3.0) ; les découpages des phases 1 (v0.4.0),
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).*
Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers,
2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe),
> v1.1.0 (onboarding/copilot) → v1.1.5 (aichat ≥ 0.30). Bilan & idées
> neuves ajoutés le 2026-08-23 (§15, §16).*
+247 -5
View File
@@ -20,24 +20,140 @@ settings:
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
self_update_repo: Projets/agent-manager # repo used by am self-update (release assets)
self_update_base_url: https://git.dracodev.net/api/v1 # serveur de release (Gitea ici; GitHub: https://api.github.com)
models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73)
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
sessions_retention_days: 90 # purge des sessions terminées au-delà de N jours (#53)
# Prix des modèles (USD par million de tokens) pour am stats --costs (#49).
# cost_models: # décommenter pour surcharger
# claude-sonnet-4: { input_per_mtok: 3.0, output_per_mtok: 15.0 }
# deepseek-chat: { input_per_mtok: 0.27, output_per_mtok: 1.10 }
# Seuils d'alerte de am monitor (#50) : 0 désactive le seuil.
# monitor_thresholds:
# cpu_pct: 80.0
# mem_mb: 2048
# Plugins d'événements (#75) : scripts exécutés sur on_install/on_start/
# on_stop/on_update, contrat JSON stdin/stdout, timeout, échec non bloquant.
# Le répertoire <state>/plugins/ contient les scripts (+ manifeste .yaml).
# plugins:
# timeout_secs: 10 # temps max d'un plugin (défaut 10 s)
# enabled: [notify] # vide = tous les plugins du répertoire
# Sauvegarde git de l'état (#66) : dépôt où am sync pousse state.json,
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
# sync_repo: https://git.dracodev.net/bruno/am-state.git
# sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in)
# Télémétrie anonyme opt-in (#76) : compteurs agrégés uniquement (jamais de
# chemins, commandes ni identifiants). Désactivée par défaut — RIEN n'est
# collecté ni envoyé tant que enabled n'est pas explicitement true.
# telemetry:
# enabled: false
# endpoint: https://exemple.tld/v1/ping # optionnel : envoi batch périodique
# am ask (#78) : langage naturel → commande am. Les règles locales marchent
# toujours, hors-ligne, sans dépendance. Le raffinement LLM optionnel passe
# par le registre providers (base_url + token du keyring + modèle) — seul
# le texte de la requête est envoyé, jamais les données du journal.
# ask:
# enabled: true # false désactive entièrement am ask
# provider: deepseek # optionnel (défaut: settings.default_provider)
# model: deepseek-chat # optionnel (défaut: modèle par défaut du provider)
# Registre communautaire (#77) : sources de catalogues de confiance +
# auteur par défaut pour am registry publish. L'installation d'une source
# inconnue exige une confirmation (checksum sha256 vérifié avant tout).
# registry:
# sources:
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
# author: bruno
# am ai (#96 #97) : politique de sécurité Shell AI. dry-run par défaut —
# aucune commande modifiante n'est exécutée sans confirmation explicite
# (--yes). `confirm` demande validation pour les commandes risky ;
# `auto` exécute tout (déconseillé). Les motifs supplémentaires s'ajoutent
# aux patterns intégrés (rm -rf, dd if, mkfs, chmod -R 777, ...).
# shell_ai:
# default_safety: dry-run # dry-run | confirm | auto
# risky_patterns:
# - "rm -rf"
# - "> /dev"
# - "dd if"
# Profil sandbox par agent (#79) : commandes autorisées (binaires), répertoires
# de travail et politique réseau. Désactivé par défaut (mode non sandboxé).
# Les tentatives refusées sont journalisées (events sandbox) pour l'audit.
# Exemple dans un agent :
# sandbox:
# enabled: true
# commands: [python, git]
# dirs: ["~/workspace"]
# network: false
# Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par
# défaut. Le provider par défaut est utilisé à l'install/au run quand aucun
# n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans
# le keyring OS sous providers/<nom>/api_key (issue #89, am providers set-token).
# Pour retirer un provider du catalogue par défaut : am providers remove <nom>
# (écrit "<nom>: null" dans votre config — le null supprime l'entrée au merge).
default_provider: anthropic
providers:
anthropic:
base_url: https://api.anthropic.com/v1
default_model: claude-sonnet-4-5
models: [claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5]
openai:
base_url: https://api.openai.com/v1
default_model: gpt-5.2
models: [gpt-5.2, gpt-5.1-mini, o4-mini]
deepseek:
base_url: https://api.deepseek.com
default_model: deepseek-chat
models: [deepseek-chat, deepseek-reasoner]
google:
base_url: https://generativelanguage.googleapis.com/v1beta
default_model: gemini-2.5-pro
models: [gemini-2.5-pro, gemini-2.5-flash]
# QwenCloud (Alibaba) — deux modes de facturation, deux providers :
# - qwen-cloud-token-plan : abonnement Credits (Personal/Team), clé
# dédiée Token Plan, modèles restreints (voir docs.qwencloud.com).
# Base URL internationale (ap-southeast-1) ; la variante Pékin
# token-plan.cn-beijing.maas.aliyuncs.com sert la Chine.
# - qwen-cloud : pay-as-you-go (clé sk- Model Studio / DashScope).
qwen-cloud-token-plan:
base_url: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
default_model: qwen3.8-max
models: [qwen3.8-max, qwen3.8-flash, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, deepseek-v4-pro, glm-5.2]
qwen-cloud:
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
default_model: qwen3.7-plus
models: [qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, qwen3-coder-plus, qwen3-coder-next, deepseek-v4-pro]
openrouter:
base_url: https://openrouter.ai/api/v1
default_model: deepseek/deepseek-chat-v3-0324
models:
- deepseek/deepseek-chat-v3-0324
- anthropic/claude-sonnet-4.5
- openai/gpt-4o
- google/gemini-2.5-pro
- meta-llama/llama-3.3-70b-instruct
- mistralai/mistral-large-2411
# --- Command aliases ----------------------------------------------------------
aliases:
cc: claude-code
gemini: gemini-cli
# Gemini CLI (npm @google/gemini-cli) ne sert plus les comptes Google
# gratuits/Pro/Ultra depuis le 2026-06-18 — remplacé par Antigravity CLI.
gemini: antigravity-cli
agy: antigravity-cli
gh-copilot: github-copilot
q: amazon-q
droid: factory-droid
gjc: gajae-code
tiny: tiny-agents
continue: continue-cli
ai: aichat
# --- Groups (start/stop/restart several agents together) ----------------------
groups:
dev: [claude-code, aider, codex]
local: [atomic-agent, nanocoder, gptme, goose]
claw: [openclaw, zeroclaw, picoclaw]
mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code]
mainstream: [claude-code, opencode, antigravity-cli, codex, qwen-code]
# ============================================================================
# Agents
@@ -55,6 +171,12 @@ agents:
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: claude
provider: anthropic
config:
env_map:
api_key: ANTHROPIC_API_KEY
model: ANTHROPIC_MODEL
base_url: ANTHROPIC_BASE_URL
tags: [anthropic, assistant, source-available, mainstream]
- name: deepseek-harness
@@ -68,6 +190,7 @@ agents:
dependencies:
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
run: dsh
provider: deepseek
tags: [deepseek, harness, multi-agent, plugins, web-ui]
- name: hermes-agent
@@ -184,9 +307,26 @@ agents:
note: "projet en mode musée — maintenance 100 % automatisée"
tags: [rust, clean-room, fork]
- name: antigravity-cli
display_name: "Antigravity CLI"
description: "Agent terminal officiel de Google (successseur de Gemini CLI, fermé, binaire Go). Même harnais d'agents qu'Antigravity 2.0 : multi-agents asynchrones, skills, hooks, plugins, MCP. Auth Google via keyring système, quota partagé avec l'app desktop (surveiller /usage)."
category: coding-agent
website: https://antigravity.google/cli
install:
type: binary
repo: google-antigravity/antigravity-cli
binary: agy
run: agy
provider: google
setup_hints:
- "agy (premier lancement : setup interactif + auth Google via le trousseau)"
- "agy plugin import gemini (importe extensions/skills de l'ancien Gemini CLI)"
tags: [google, mainstream, antigravity]
note: "Remplace Gemini CLI : le 2026-06-18, Gemini CLI a cessé de servir les comptes Google gratuits/Pro/Ultra. Import des extensions/skills Gemini : 'agy plugin import gemini' ; GEMINI.md et ~/.gemini/ restent lus (config native : ~/.gemini/antigravity-cli/, skills workspace : .agents/skills/, MCP : mcp_config.json avec clé 'serverUrl')."
- name: gemini-cli
display_name: "Gemini CLI"
description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche."
display_name: "Gemini CLI (déprécié)"
description: "Ancien agent terminal Google (Apache-2.0). Ne sert plus les comptes Google gratuits/Pro/Ultra depuis le 2026-06-18 ; reste utilisable avec une clé API Gemini payante. Migrer vers antigravity-cli."
category: coding-agent
website: https://github.com/google-gemini/gemini-cli
install:
@@ -195,7 +335,9 @@ agents:
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: gemini
tags: [google, mainstream]
installable: false
tags: [google, deprecated]
note: "déprécié — remplacé par Antigravity CLI ('am install antigravity-cli'), voir la note de cette entrée"
- name: codex
display_name: "Codex CLI"
@@ -208,6 +350,11 @@ agents:
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: codex
config:
env_map:
api_key: OPENAI_API_KEY
model: OPENAI_MODEL
base_url: OPENAI_BASE_URL
tags: [openai, mainstream]
- name: openhands
@@ -324,6 +471,15 @@ agents:
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: qwen-code
provider: qwen-cloud
config:
env_map:
api_key: OPENAI_API_KEY
model: OPENAI_MODEL
base_url: OPENAI_BASE_URL
setup_hints:
- "am install qwen-code --provider qwen-cloud-token-plan (abonnement Credits Token Plan au lieu du pay-as-you-go)"
- "am providers set-token qwen-cloud-token-plan (clé dédiée Token Plan ; pay-as-you-go : clé sk- Model Studio)"
tags: [alibaba, qwen]
- name: grok-build
@@ -458,6 +614,15 @@ agents:
repo: 1ay1/agentty
binary: agentty
run: agentty
config:
provider_default: openai
env_map:
api_key: AGENTTY_API_KEY
model: AGENTTY_MODEL
base_url: AGENTTY_BASE_URL
files:
- path: ~/.config/agentty/config.toml
keys: [model, base_url]
tags: [c++, static, sandbox]
- name: nullclaw
@@ -700,6 +865,9 @@ agents:
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: "npx lazycodex-ai"
# Sonde PATH explicite : le premier mot de `run` est npx (interpréteur) —
# sans `detect`, npx sur le PATH suffirait à déclarer l'agent « external ».
detect: lazycodex-ai
note: "s'intègre dans Codex (nécessite @openai/codex)"
tags: [harness, codex, plan]
@@ -772,6 +940,71 @@ agents:
note: "étapes de build spécifiques au repo — consultez son README"
tags: [simulator, orchestration]
# ---------------------------------------------------------------- Shell AI
- name: aichat
display_name: "AIChat"
description: "All-in-one LLM CLI avec Shell Assistant, Chat-REPL, RAG, AI Tools & Agents et support fichiers/répertoires (-f). Idéal pour exécuter des micro-tâches shell via langage naturel (issue #94)."
category: shell-ai
website: https://github.com/sigoden/aichat
install:
type: binary
repo: sigoden/aichat
binary: aichat
run: aichat
tags: [shell, rust, multi-provider, rag, local, fast]
- name: ai-cli
display_name: "AI CLI"
description: "Traduit le langage naturel en commandes shell (OpenAI, OpenRouter ou serveur local) avec politique de sécurité (risk / certainty) avant exécution (issue #95)."
category: shell-ai
website: https://github.com/kriserickson/ai-cli
install:
type: binary
repo: kriserickson/ai-cli
binary: ai
run: ai
tags: [shell, go, safety, multi-provider]
- name: fabric
display_name: "Fabric"
description: "Framework open source pour augmenter les humains avec l'IA : patterns réutilisables (summarize, extract wisdom, analyse de contenu), CLI Go 43k+ stars (issue #95)."
category: shell-ai
website: https://github.com/danielmiessler/fabric
install:
type: go
package: github.com/danielmiessler/fabric/cmd/fabric@latest
dependencies:
- { name: go, min_version: "1.22.0", install_hint: "https://go.dev/dl" }
run: fabric
tags: [patterns, go, content]
- name: shell-ai
display_name: "Shell AI"
description: "Agent ReAct local-first propulsé par Ollama : zéro réseau, outils filesystem, exécution de commandes shell et intégration MCP (issue #95)."
category: shell-ai
website: https://github.com/nishant9083/shell-ai
install:
type: npm
package: "@shell-ai/cli"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: shell-ai
note: "nécessite Ollama et un modèle (ollama pull gpt-oss) — agent jeune (15 stars)"
tags: [shell, ollama, local, mcp, typescript]
- name: shellgpt
display_name: "ShellGPT"
description: "Outil de productivité CLI propulsé par LLM (GPT-5…) : génère et exécute des commandes shell, écrit du code et des docs, 12k+ stars (issue #95)."
category: shell-ai
website: https://github.com/TheR1D/shell_gpt
install:
type: pip
package: shell-gpt
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: sgpt
tags: [shell, python, multi-provider]
# ----------------------------------------------------- Écosystème OpenClaw
- name: openclaw
display_name: "OpenClaw"
@@ -797,6 +1030,9 @@ agents:
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: "python -m nanobot"
# Sonde PATH explicite : le premier mot de `run` est python (interpréteur) —
# sans `detect`, /usr/bin/python suffirait à déclarer l'agent « external ».
detect: nanobot
tags: [python, lightweight]
- name: zeroclaw
@@ -834,6 +1070,12 @@ agents:
repo: sipeed/picoclaw
binary: picoclaw
run: picoclaw
# PicoClaw gère sa config par sous-commandes (pas de fichier à écrire) :
# ces commandes sont affichées après l'install avec {model} substitué.
setup_hints:
- "picoclaw onboard"
- "picoclaw model {model}"
- "picoclaw auth login"
tags: [go, tiny]
- name: ironclaw
+8
View File
@@ -0,0 +1,8 @@
# Tâche de benchmark (issue #62) : les mêmes arguments sont passés à la
# commande de chaque agent. Copier dans <state_dir>/lab/ ou passer un
# chemin explicite :
# am lab --agents claude-code,aider --task hello.yaml --json
name: hello
description: "Répondre à la question la plus simple (sanity check du lab)."
args: ["--prompt", "Réponds uniquement 'coucou' et rien d'autre."]
# timeout: 300 # secondes par agent (défaut 600) — --timeout l'emporte
+31
View File
@@ -0,0 +1,31 @@
# Plugin scripts — agent-manager (issue #75)
#
# Les plugins sont des points d'extension par script, exécutés sur les
# événements du cycle de vie des agents (complément des hooks de l'axe 6) :
# on_install · on_start · on_stop · on_update
#
# Contrat :
# - entrée : l'événement JSON sur stdin
# {"event":"on_start","kind":"start","ts":"…","agent":"claude-code",
# "pid":1234,"cwd":"…","project":"…","args":[…],"env_keys":[…],…}
# - sortie : un objet JSON sur stdout
# {"ok": true, "message": "…", "data": {…}}
# ou {"ok": false, "error": "…"} — signalé en warning, jamais fatal
# - une sortie invalide (pas de JSON) est signalée en warning sans casser am
# - un plugin qui dépasse son timeout est tué (défaut 10 s)
# - les plugins n'ont pas accès aux valeurs des secrets (env_keys seulement)
# - AM_PLUGINS_RUNNING=1 est exporté pendant un plugin : un am imbriqué
# saute les plugins (anti-récursion)
#
# Installation :
# cp notify.py notify.yaml ~/.local/state/agent-manager/plugins/ (Linux)
# cp notify.py notify.yaml %LOCALAPPDATA%\agent-manager\plugins\ (Windows)
#
# Vérification :
# am plugins # liste les plugins découverts
# am plugins --test notify # exécute le contrat sur un événement de test
#
# Configuration (config.yaml) :
# plugins:
# timeout_secs: 10 # temps max d'un plugin (défaut 10 s)
# enabled: [notify] # vide = tous les plugins du répertoire
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Plugin exemple (issue #75) : poste les événements install/update vers un
webhook CI. Si la variable d'environnement CI_WEBHOOK_URL n'est pas définie,
le plugin répond ok sans rien faire (échec non bloquant, jamais fatal).
Exemple : CI_WEBHOOK_URL=https://hooks.example.com/am-ci am update --all
"""
import json
import os
import sys
import urllib.request
def main() -> int:
ev = json.load(sys.stdin)
url = os.environ.get("CI_WEBHOOK_URL")
if not url:
print(json.dumps({"ok": True, "message": "CI_WEBHOOK_URL non définie — rien à faire"}))
return 0
req = urllib.request.Request(
url,
data=json.dumps(ev).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
status = resp.status
except Exception as exc: # échec non bloquant : warning seulement
print(json.dumps({"ok": False, "error": f"webhook: {exc}"}))
return 0
print(json.dumps({"ok": True, "message": f"webhook {status}"}))
return 0
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -0,0 +1,4 @@
# Manifest du plugin ci-webhook (issue #75).
command: "python ci-webhook.py" # Linux : "python3 ci-webhook.py"
events: [on_install, on_update]
timeout_secs: 10
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Plugin exemple (issue #75) : notifie le démarrage / l'arrêt d'un agent.
Contrat : l'événement JSON arrive sur stdin, la réponse JSON part sur stdout.
Installation : copier notify.py + notify.yaml dans <state_dir>/plugins/ puis
relancer am. Test : am plugins --test notify
"""
import json
import sys
def main() -> int:
ev = json.load(sys.stdin)
agent = ev.get("agent") or "inconnu"
event = ev.get("event", "?")
pid = ev.get("pid")
detail = f" (pid {pid})" if pid else ""
print(json.dumps({"ok": True, "message": f"{event} de {agent}{detail}"}))
return 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -0,0 +1,7 @@
# Manifest du plugin notify (issue #75).
# command: interpréteur + script (indispensable sur Windows pour .py).
command: "python notify.py" # Linux : "python3 notify.py"
# Événements écoutés (défaut : les 4 événements du cycle de vie).
events: [on_start, on_stop]
# Temps maximal d'exécution avant kill (défaut : settings.plugins.timeout_secs, 10).
timeout_secs: 5
+11 -1
View File
@@ -24,7 +24,7 @@ function Install-FromSource {
Write-Host "Installation depuis les sources (cargo install --git $BaseUrl.git) ..."
cargo install --git "$BaseUrl.git" --locked
if ($LASTEXITCODE -ne 0) { Fail "cargo install a echoue" }
$bin = Join-Path $env:USERPROFILE ".cargo\binam.exe"
$bin = Join-Path $env:USERPROFILE ".cargo\bin\am.exe"
Write-Host ""
Write-Host "OK - am est installe dans $bin"
Write-Host "Essayez : am list | am doctor (mise a jour : relancez ce script)"
@@ -47,7 +47,10 @@ if ($arch -eq "x86_64") {
$tmpDir = Join-Path $env:TEMP "am-$tag"
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $tmp -DestinationPath $tmpDir -Force
# L'entrée de l'archive varie selon le pipeline : « am.exe » ou
# « am-windows-x86_64.exe » (nom de l'asset). Accepter les deux.
$exe = Join-Path $tmpDir "am.exe"
if (-not (Test-Path $exe)) { $exe = Join-Path $tmpDir "am-windows-x86_64.exe" }
if (Test-Path $exe) {
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
Copy-Item $exe (Join-Path $binDir "am.exe") -Force
@@ -75,6 +78,13 @@ if (-not $InstallDir) {
$env:Path = "$binDir;$env:Path"
& (Join-Path $binDir "am.exe") --version
Write-Host ""
# Épique v1.1.0 : wizard d'onboarding (provider + token + aichat + rôles)
# uniquement quand stdin est un terminal interactif (pas en CI).
if ([Console]::IsInputRedirected -eq $false) {
Write-Host "Configuration initiale (provider, token, aichat) :"
& (Join-Path $binDir "am.exe") setup
}
Write-Host ""
Write-Host "OK - 'am' est installe. Essayez :"
Write-Host " am list"
Write-Host " am doctor"
+26 -3
View File
@@ -22,6 +22,13 @@ case "$arch" in
*) fail "architecture non supportée: $arch" ;;
esac
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$os" in
linux) platform="linux" ;;
darwin) platform="macos" ;;
*) fail "système non supporté: $os" ;;
esac
install_from_source() {
if ! command -v cargo >/dev/null 2>&1; then
fail "pas de binaire précompilé pour $arch et cargo est absent — installez Rust (https://rustup.rs) puis relancez"
@@ -45,14 +52,23 @@ fi
installed=""
if [ -n "$tag" ] && command -v curl >/dev/null 2>&1; then
asset="am-linux-$arch.tar.gz"
asset="am-$platform-$arch.tar.gz"
url="$BASE_URL/releases/download/$tag/$asset"
echo "Téléchargement de $url …"
if curl -fsSL --connect-timeout 20 -o "$tmpdir/am.tar.gz" "$url"; then
tar -xzf "$tmpdir/am.tar.gz" -C "$tmpdir" 2>/dev/null || true
if [ -f "$tmpdir/am" ]; then
# L'entrée de l'archive varie selon le pipeline CI : « am » ou
# « am-linux-x86_64 » (nom de la release). Prendre le premier fichier
# extrait.
bin=""
for cand in "$tmpdir/am" "$tmpdir/am-$platform-$arch" "$tmpdir/am-$arch" "$tmpdir"/*; do
if [ -f "$cand" ] && [ -z "$bin" ]; then
bin="$cand"
fi
done
if [ -n "$bin" ]; then
mkdir -p "$INSTALL_DIR"
cp "$tmpdir/am" "$INSTALL_DIR/am"
cp "$bin" "$INSTALL_DIR/am"
chmod +x "$INSTALL_DIR/am"
installed=1
fi
@@ -77,6 +93,13 @@ esac
"$INSTALL_DIR/am" --version || true
echo ""
# Épique v1.1.0 : wizard d'onboarding (provider + token + aichat + rôles)
# uniquement quand stdin est un terminal interactif (pas en CI).
if [ -t 0 ]; then
echo "Configuration initiale (provider, token, aichat) :"
"$INSTALL_DIR/am" setup
fi
echo ""
echo "OK — 'am' est installé. Essayez :"
echo " am list"
echo " am doctor"
+31
View File
@@ -0,0 +1,31 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-ai 1 "ai "
.SH NAME
ai \- Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.SH SYNOPSIS
\fBai\fR [\fB\-e\fR|\fB\-\-exec\fR] [\fB\-f\fR|\fB\-\-files\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-\-role\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIPROMPT\fR>
.SH DESCRIPTION
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.SH OPTIONS
.TP
\fB\-e\fR, \fB\-\-exec\fR
Execute the generated shell command (after the safety policy)
.TP
\fB\-f\fR, \fB\-\-files\fR \fI<PATH>\fR
Files or directories passed as context (repeatable; default: the current directory)
.TP
\fB\-\-provider\fR \fI<ID>\fR
Provider of the registry used by aichat (env vars + model)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Force a model (aichat \-\-model)
.TP
\fB\-\-role\fR \fI<ROLE>\fR
Use one of the generated copilot roles (am\-copilot, am\-operator, am\-dev, am\-do, am\-analyst, am\-orchestrator — issue v1.1.0 F3)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIPROMPT\fR>
The request in natural language
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-ask 1 "ask "
.SH NAME
ask \- Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.SH SYNOPSIS
\fBask\fR [\fB\-\-yes\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIREQUEST\fR>
.SH DESCRIPTION
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.SH OPTIONS
.TP
\fB\-\-yes\fR
Skip the confirmation prompt
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIREQUEST\fR>
The request in natural language
+13
View File
@@ -0,0 +1,13 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-audit 1 "audit "
.SH NAME
audit \- Audit the configuration: who changed what, when (issue #63)
.SH SYNOPSIS
\fBaudit\fR [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Audit the configuration: who changed what, when (issue #63)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+26
View File
@@ -0,0 +1,26 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-catalog 1 "catalog "
.SH NAME
catalog \- Manage remote catalogs: update the official one, add external ones
.SH SYNOPSIS
\fBcatalog\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Manage remote catalogs: update the official one, add external ones
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
catalog\-update(1)
Fetch the official catalog, show the diff and register it as an include
.TP
catalog\-add(1)
Register an external catalog (same YAML format) by URL
.TP
catalog\-list(1)
List the registered remote catalogs
.TP
catalog\-help(1)
Print this message or the help of the given subcommand(s)
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
doctor \- Check the environment: tools, config validity, paths, permissions
.SH SYNOPSIS
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-\-watch\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Check the environment: tools, config validity, paths, permissions
.SH OPTIONS
@@ -12,5 +12,8 @@ Check the environment: tools, config validity, paths, permissions
\fB\-\-fix\fR
Attempt to repair problems (create directories, fix the state file)
.TP
\fB\-\-watch\fR \fI<SECONDS>\fR
Re\-run the checks every N seconds, alerting on failure (issue #59)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+7 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
history \- Search the command history (am and shell commands)
.SH SYNOPSIS
\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-\-save\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIRANGE\fR]
.SH DESCRIPTION
Search the command history (am and shell commands)
.SH OPTIONS
@@ -30,5 +30,11 @@ Maximum number of entries (default: 100)
\fB\-\-rerun\fR \fI<N>\fR
Re\-run the Nth most recent entry (with confirmation)
.TP
\fB\-\-save\fR \fI<FILE>\fR
Export the range as a playbook YAML file (issue #51)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fIRANGE\fR]
Range to export as a playbook: "12..25" (1\-based, newest first)
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
init \- Generate a local agent\-manager.yaml for the current directory
.SH SYNOPSIS
\fBinit\fR [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBinit\fR [\fB\-\-force\fR] [\fB\-\-template\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Generate a local agent\-manager.yaml for the current directory
.SH OPTIONS
@@ -12,5 +12,8 @@ Generate a local agent\-manager.yaml for the current directory
\fB\-\-force\fR
Overwrite an existing agent\-manager.yaml
.TP
\fB\-\-template\fR \fI<TEMPLATE>\fR
Use a template (web, python, rust, cli) instead of stack detection; "list" shows the available templates
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+10 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
install \- Install an agent and its dependencies
.SH SYNOPSIS
\fBinstall\fR [\fB\-\-method\fR] [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR>
\fBinstall\fR [\fB\-\-method\fR] [\fB\-\-force\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-\-no\-config\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR>
.SH DESCRIPTION
Install an agent and its dependencies
.SH OPTIONS
@@ -15,6 +15,15 @@ Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, b
\fB\-\-force\fR
Reinstall even if already installed
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to configure (default: the agent\*(Aqs provider, else settings.default_provider) (issue #90)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Model to configure (default: the provider\*(Aqs default model) (issue #90)
.TP
\fB\-\-no\-config\fR
Skip the post\-install provider configuration (issue #91)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+28
View File
@@ -0,0 +1,28 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-lab 1 "lab "
.SH NAME
lab \- Benchmark: run the same task on several agents and compare (issue #62)
.SH SYNOPSIS
\fBlab\fR [\fB\-\-agents\fR] [\fB\-\-task\fR] [\fB\-\-parallel\fR] [\fB\-\-timeout\fR] [\fB\-\-list\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Benchmark: run the same task on several agents and compare (issue #62)
.SH OPTIONS
.TP
\fB\-\-agents\fR \fI<AGENTS>\fR
Comma\-separated agent names (or aliases) to benchmark
.TP
\fB\-\-task\fR \fI<FILE>\fR
Task file (a bare name resolves under state_dir/lab/, .yaml appended)
.TP
\fB\-\-parallel\fR
Run every agent concurrently instead of one after another
.TP
\fB\-\-timeout\fR \fI<SECS>\fR
Override the task timeout in seconds
.TP
\fB\-\-list\fR
List the task files of the lab directory
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+22
View File
@@ -0,0 +1,22 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-migrate 1 "migrate "
.SH NAME
migrate \- Transfer the installation and state to another machine (issue #68)
.SH SYNOPSIS
\fBmigrate\fR [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIBUNDLE\fR]
.SH DESCRIPTION
Transfer the installation and state to another machine (issue #68)
.SH OPTIONS
.TP
\fB\-\-export\fR
Export the bundle (default) or import one
.TP
\fB\-\-output\fR \fI<FILE>\fR
Bundle path for export (default agent\-manager\-migrate.amx)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fIBUNDLE\fR]
Bundle path to import
+25
View File
@@ -0,0 +1,25 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-models 1 "models "
.SH NAME
models \- List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.SH SYNOPSIS
\fBmodels\fR [\fB\-\-prune\fR] [\fB\-\-dry\-run\fR] [\fB\-\-yes\fR] [\fB\-\-days\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.SH OPTIONS
.TP
\fB\-\-prune\fR
Purge unused models instead of listing them
.TP
\fB\-\-dry\-run\fR
Only list what would be pruned, delete nothing
.TP
\fB\-\-yes\fR
Answer yes to the deletion prompt
.TP
\fB\-\-days\fR \fI<DAYS>\fR
Consider a model unused after N days (default: 30)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-monitor 1 "monitor "
.SH NAME
monitor \- Real\-time monitor of the managed processes (issue #50)
.SH SYNOPSIS
\fBmonitor\fR [\fB\-\-interval\fR] [\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Real\-time monitor of the managed processes (issue #50)
.SH OPTIONS
.TP
\fB\-\-interval\fR \fI<SECONDS>\fR
Refresh interval in seconds (default 2)
.TP
\fB\-\-json\fR
Print one JSON document per tick instead of the TUI
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-playbook 1 "playbook "
.SH NAME
playbook \- Replay a saved playbook step by step with confirmation (issue #51)
.SH SYNOPSIS
\fBplaybook\fR [\fB\-\-var\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIFILE\fR>
.SH DESCRIPTION
Replay a saved playbook step by step with confirmation (issue #51)
.SH OPTIONS
.TP
\fB\-\-var\fR \fI<NAME=VALUE>\fR
Variable values, repeatable: \-\-var name=value
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIFILE\fR>
Playbook file (state_dir/playbooks/ when a bare name is given)
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-plugins 1 "plugins "
.SH NAME
plugins \- List the event plugins and test one (issue #75)
.SH SYNOPSIS
\fBplugins\fR [\fB\-\-test\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
List the event plugins and test one (issue #75)
.SH OPTIONS
.TP
\fB\-\-test\fR \fI<NAME>\fR
Run one plugin against a synthetic event and print its response
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+38
View File
@@ -0,0 +1,38 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-providers 1 "providers "
.SH NAME
providers \- Manage the LLM provider registry: base URLs, models, default provider
.SH SYNOPSIS
\fBproviders\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
Manage the LLM provider registry: base URLs, models, default provider
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
providers\-list(1)
List the registered providers (the default one is starred)
.TP
providers\-show(1)
Show one provider in detail (base URL, models, default model)
.TP
providers\-add(1)
Register a provider, or update an existing one
.TP
providers\-remove(1)
Remove a provider from the registry
.TP
providers\-set\-token(1)
Store the provider API token in the OS keyring (shared by every agent of this provider through the @secret cascade, issue #89)
.TP
providers\-token(1)
Check whether a provider token exists (the value is never shown)
.TP
providers\-default(1)
Set the default provider
.TP
providers\-help(1)
Print this message or the help of the given subcommand(s)
+29
View File
@@ -0,0 +1,29 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-registry 1 "registry "
.SH NAME
registry \- Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
.SH SYNOPSIS
\fBregistry\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
registry\-publish(1)
Prepare a catalog + manifest (source, version, author, sha256) for publication on Gitea
.TP
registry\-search(1)
Search agents across the registered sources (settings.registry.sources)
.TP
registry\-install(1)
Install a catalog from the registry: manifest + checksum validation, then an explicit trust decision for unknown sources
.TP
registry\-list(1)
List the registered sources
.TP
registry\-help(1)
Print this message or the help of the given subcommand(s)
+16 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
restart \- Restart an agent: stop, then start with the same options
.SH SYNOPSIS
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Restart an agent: stop, then start with the same options
.SH OPTIONS
@@ -27,6 +27,21 @@ Send a desktop notification once the agent has started
\fB\-\-profile\fR \fI<NAME>\fR
Apply an environment profile (env + args, defined in the config)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): resolved from the registry
.TP
\fB\-\-no\-sandbox\fR
Skip the agent\*(Aqs sandbox profile for this run (issue #79)
.TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-\-force\fR
Kill immediately instead of terminating gracefully
.TP
+13 -1
View File
@@ -4,11 +4,23 @@
.SH NAME
run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS
\fBrun\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
.SH DESCRIPTION
Run the agent command directly with the given arguments (no process management)
.SH OPTIONS
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
.TP
\fB\-\-no\-sandbox\fR
Skip the agent\*(Aqs sandbox profile (issue #79)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+29
View File
@@ -0,0 +1,29 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-schedule 1 "schedule "
.SH NAME
schedule \- Schedule am commands (issue #56)
.SH SYNOPSIS
\fBschedule\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Schedule am commands (issue #56)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
schedule\-add(1)
Add a daily schedule (ex: \*(Aqam schedule add update \-\-all\*(Aq)
.TP
schedule\-list(1)
List the schedules
.TP
schedule\-remove(1)
Remove a schedule by id
.TP
schedule\-run(1)
Run a schedule now (invoked by the OS task, issue #56)
.TP
schedule\-help(1)
Print this message or the help of the given subcommand(s)
+1 -1
View File
@@ -14,7 +14,7 @@ Print help
.SH SUBCOMMANDS
.TP
secret\-set(1)
Store a secret for an agent
Store a secret for an agent (or a provider, issue #89)
.TP
secret\-unset(1)
Remove a secret
+2 -2
View File
@@ -2,11 +2,11 @@
.el .ds Aq '
.TH am-self-update 1 "self-update "
.SH NAME
self\-update \- Update agent\-manager itself from GitHub Releases
self\-update \- Update agent\-manager itself from the latest release
.SH SYNOPSIS
\fBself\-update\fR [\fB\-\-check\fR] [\fB\-\-to\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Update agent\-manager itself from GitHub Releases
Update agent\-manager itself from the latest release
.SH OPTIONS
.TP
\fB\-\-check\fR
+25
View File
@@ -0,0 +1,25 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-serve 1 "serve "
.SH NAME
serve \- Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
.SH SYNOPSIS
\fBserve\fR [\fB\-\-token\fR] [\fB\-\-host\fR] [\fB\-\-port\fR] [\fB\-\-rate\-limit\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
.SH OPTIONS
.TP
\fB\-\-token\fR \fI<TOKEN>\fR
Bearer token required on every request (mandatory)
.TP
\fB\-\-host\fR \fI<HOST>\fR
Listen address (default 127.0.0.1)
.TP
\fB\-\-port\fR \fI<PORT>\fR
Listening port (default 8080)
.TP
\fB\-\-rate\-limit\fR \fI<N>\fR
Per\-IP request budget per minute (default 120)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+26
View File
@@ -0,0 +1,26 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-service 1 "service "
.SH NAME
service \- Register an agent as a system service (issue #55)
.SH SYNOPSIS
\fBservice\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Register an agent as a system service (issue #55)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
service\-install(1)
Install the service unit (systemd / launchd / Task Scheduler)
.TP
service\-uninstall(1)
Remove the service unit
.TP
service\-status(1)
Show whether the service is installed
.TP
service\-help(1)
Print this message or the help of the given subcommand(s)
+10 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
sessions \- List the sessions of every agent (and of the interactive shell)
.SH SYNOPSIS
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-\-retention\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
List the sessions of every agent (and of the interactive shell)
.SH OPTIONS
@@ -21,6 +21,15 @@ Show one session in detail (summary + log excerpt)
\fB\-\-resume\fR \fI<ID>\fR
Resume a finished session: relaunch the agent with its recorded arguments
.TP
\fB\-\-export\fR \fI<ID>\fR
Export one session (metadata, commands, log excerpt) to a JSON file
.TP
\fB\-\-output\fR \fI<FILE>\fR
Destination of the export (default: ./am\-session\-<ID>.json)
.TP
\fB\-\-retention\fR \fI<DAYS>\fR
Purge sessions finished more than N days ago (settings default 90)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-setup 1 "setup "
.SH NAME
setup \- Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
.SH SYNOPSIS
\fBsetup\fR [\fB\-\-roles\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
.SH OPTIONS
.TP
\fB\-\-roles\fR
Only (re)generate the am\-* copilot roles for aichat
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+16 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
start \- Start an agent (foreground by default, or detached with \-\-background)
.SH SYNOPSIS
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Start an agent (foreground by default, or detached with \-\-background)
.SH OPTIONS
@@ -27,6 +27,21 @@ Send a desktop notification once the agent has started
\fB\-\-profile\fR \fI<NAME>\fR
Apply an environment profile (env + args, defined in the config)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): resolved from the registry
.TP
\fB\-\-no\-sandbox\fR
Skip the agent\*(Aqs sandbox profile for this run (issue #79)
.TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
stats \- Show usage statistics computed from the event journal
.SH SYNOPSIS
\fBstats\fR [\fB\-\-period\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBstats\fR [\fB\-\-period\fR] [\fB\-\-costs\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Show usage statistics computed from the event journal
.SH OPTIONS
@@ -12,6 +12,9 @@ Show usage statistics computed from the event journal
\fB\-\-period\fR \fI<PERIOD>\fR
Only events of the last period (7d, 30d, 90d, all)
.TP
\fB\-\-costs\fR
Show token usage and estimated spend per agent (issue #49)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-suggest 1 "suggest "
.SH NAME
suggest \- Suggest agents matching a query, boosted by real usage (issue #61)
.SH SYNOPSIS
\fBsuggest\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIQUERY\fR>
.SH DESCRIPTION
Suggest agents matching a query, boosted by real usage (issue #61)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIQUERY\fR>
Free\-form request ("un agent pour du Python")
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-sync 1 "sync "
.SH NAME
sync \- Push the state into the configured git repository (issue #66)
.SH SYNOPSIS
\fBsync\fR [\fB\-\-message\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Push the state into the configured git repository (issue #66)
.SH OPTIONS
.TP
\fB\-\-message\fR \fI<MESSAGE>\fR
Commit message (default: "am sync — state update")
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-theme 1 "theme "
.SH NAME
theme \- Set or show the active color theme (persisted for the next run)
.SH SYNOPSIS
\fBtheme\fR [\fB\-h\fR|\fB\-\-help\fR] [\fINAME\fR]
.SH DESCRIPTION
Set or show the active color theme (persisted for the next run)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fINAME\fR]
Theme name (omit to list available themes)
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-tip 1 "tip "
.SH NAME
tip \- Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.SH SYNOPSIS
\fBtip\fR [\fB\-\-random\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.SH OPTIONS
.TP
\fB\-\-random\fR
Show a single random tip instead of the whole page
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
update \- Update an installed agent to the latest available version
.SH SYNOPSIS
\fBupdate\fR [\fB\-\-all\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBupdate\fR [\fB\-\-all\fR] [\fB\-\-rollback\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Update an installed agent to the latest available version
.SH OPTIONS
@@ -12,6 +12,9 @@ Update an installed agent to the latest available version
\fB\-\-all\fR
Update every installed managed agent
.TP
\fB\-\-rollback\fR [\fI<ROLLBACK>\fR]
Roll back to a pre\-update backup instead of updating ("latest" or a backup id; "\-\-rollback list" shows them)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-web 1 "web "
.SH NAME
web \- Local read\-only web dashboard with charts (issue #54)
.SH SYNOPSIS
\fBweb\fR [\fB\-\-port\fR] [\fB\-\-no\-open\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Local read\-only web dashboard with charts (issue #54)
.SH OPTIONS
.TP
\fB\-\-port\fR \fI<PORT>\fR
Port to listen on (default 7878)
.TP
\fB\-\-no\-open\fR
Do not open the browser automatically
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+80 -4
View File
@@ -1,10 +1,10 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.4.1"
.TH am 1 "am 1.1.9"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-\-lang\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
.SH DESCRIPTION
agent\-manager (am) — a CLI to manage local AI coding agents.
.PP
@@ -38,6 +38,19 @@ Disable ANSI colors
\fB\-\-theme\fR \fI<THEME>\fR
Color theme for the output (see \*(Aqam help theme\*(Aq for the list)
.TP
\fB\-\-lang\fR \fI<LANG>\fR
Output language: fr (default) or en (issue #74)
.br
.br
\fIPossible values:\fR
.RS 14
.IP \(bu 2
fr
.IP \(bu 2
en
.RE
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help (see a summary with \*(Aq\-h\*(Aq)
.TP
@@ -84,12 +97,48 @@ Manage environment profiles (dev, prod, ...)
am\-man(1)
Generate a man page for am (or for one command)
.TP
am\-theme(1)
Set or show the active color theme (persisted for the next run)
.TP
am\-alias(1)
Manage command aliases (cc \-> claude\-code)
.TP
am\-secret(1)
Manage secrets in the OS keyring (never in plaintext config)
.TP
am\-audit(1)
Audit the configuration: who changed what, when (issue #63)
.TP
am\-service(1)
Register an agent as a system service (issue #55)
.TP
am\-schedule(1)
Schedule am commands (issue #56)
.TP
am\-models(1)
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.TP
am\-registry(1)
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
.TP
am\-catalog(1)
Manage remote catalogs: update the official one, add external ones
.TP
am\-providers(1)
Manage the LLM provider registry: base URLs, models, default provider
.TP
am\-suggest(1)
Suggest agents matching a query, boosted by real usage (issue #61)
.TP
am\-ask(1)
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.TP
am\-ai(1)
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.TP
am\-setup(1)
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
.TP
am\-start(1)
Start an agent (foreground by default, or detached with \-\-background)
.TP
@@ -108,6 +157,21 @@ List the sessions of every agent (and of the interactive shell)
am\-stats(1)
Show usage statistics computed from the event journal
.TP
am\-monitor(1)
Real\-time monitor of the managed processes (issue #50)
.TP
am\-web(1)
Local read\-only web dashboard with charts (issue #54)
.TP
am\-serve(1)
Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
.TP
am\-sync(1)
Push the state into the configured git repository (issue #66)
.TP
am\-migrate(1)
Transfer the installation and state to another machine (issue #68)
.TP
am\-top(1)
Show the most used agents (top 10)
.TP
@@ -138,6 +202,15 @@ Search the catalog by keyword (name, description, category, tags)
am\-history(1)
Search the command history (am and shell commands)
.TP
am\-playbook(1)
Replay a saved playbook step by step with confirmation (issue #51)
.TP
am\-lab(1)
Benchmark: run the same task on several agents and compare (issue #62)
.TP
am\-plugins(1)
List the event plugins and test one (issue #75)
.TP
am\-info(1)
Show detailed information about one agent
.TP
@@ -150,6 +223,9 @@ Display help: the overview, a command, the command list, or a search
am\-version(1)
Display version and build information
.TP
am\-tip(1)
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.TP
am\-config(1)
Manage the configuration file
.TP
@@ -163,7 +239,7 @@ am\-completion(1)
Generate a shell completion script
.TP
am\-self\-update(1)
Update agent\-manager itself from GitHub Releases
Update agent\-manager itself from the latest release
.TP
am\-self\-uninstall(1)
Remove agent\-manager and everything it created from this machine
@@ -174,4 +250,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.4.1
v1.1.9
+11 -2
View File
@@ -12,9 +12,18 @@ $zip = "dist/am-windows-x86_64.zip"
Remove-Item $zip -ErrorAction SilentlyContinue
Compress-Archive -Path target/release/am.exe -DestinationPath $zip
function Get-Sha256Net([string]$path) {
$stream = [System.IO.File]::OpenRead($path)
try {
$sha = [System.Security.Cryptography.SHA256]::Create()
$bytes = $sha.ComputeHash($stream)
return ([BitConverter]::ToString($bytes) -replace '-', '').ToLower()
} finally { $stream.Close() }
}
Write-Host ""
Write-Host "Artefacts:"
Get-ChildItem dist | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
Get-ChildItem dist -File | ForEach-Object {
$hash = Get-Sha256Net $_.FullName
Write-Host (" {0,-32} {1} sha256:{2}" -f $_.Name, $_.Length, $hash)
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Renders the packaging templates (scoop, winget, homebrew, rpm) with the
# version and the sha256 of every release archive.
# Usage: scripts/render-manifests.ps1 -Version 0.4.1 [-DistDir dist]
# Usage: scripts/render-manifests.ps1 -Version 0.4.3 [-DistDir dist]
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$DistDir = "dist"
+298
View File
@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am web — Cockpit sombre (mockup)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
}
body {
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
}
.layout { display:flex; min-height:100vh; }
/* ---------- Sidebar ---------- */
.side {
width:225px; flex-shrink:0; background:var(--panel2);
border-right:1px solid var(--border); padding:18px 12px;
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
}
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
.logo-badge {
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
}
.logo b { font-size:16px; letter-spacing:.5px; }
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
.nav-item {
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
}
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
.nav-item .ico { width:18px; text-align:center; }
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; }
/* ---------- Main ---------- */
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
.topbar h1 { font-size:20px; font-weight:700; }
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
.top-actions { display:flex; align-items:center; gap:10px; }
.badge {
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
}
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
.badge.live::before { content:"● "; }
.btn {
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
font-weight:700; font-size:13px; cursor:pointer;
}
.btn:hover { filter:brightness(1.1); }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
/* ---------- KPI cards ---------- */
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
.kpi .delta { font-size:11.5px; margin-top:4px; }
.kpi .delta.up { color:var(--ok); } .kpi .delta.down { color:var(--err); }
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
/* ---------- Panels ---------- */
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
/* bars */
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); }
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
/* trend svg */
.trend-wrap { position:relative; }
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
/* tables */
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
tbody tr:hover { background:rgba(61,220,151,.05); }
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; }
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
.search:focus { outline:none; border-color:var(--accent); }
/* timeline */
.tl { list-style:none; }
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
.panel.hidden, .view { display:none; } .view.active { display:block; }
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
</style>
</head>
<body>
<div class="layout">
<aside class="side">
<div class="logo">
<div class="logo-badge">am</div>
<div><b>agent-manager</b><small>cockpit des agents</small></div>
</div>
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">Vue d'ensemble</span></div>
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">Statistiques</span></div>
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">Événements</span></div>
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">Projets</span></div>
<div class="side-foot">
<span><span class="dot"></span>v0.6.0 · lang FR ·<br>127.0.0.1:7878</span>
</div>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 id="view-title">Vue d'ensemble</h1>
<div class="sub" id="last-update">Dernière mise à jour : il y a 5 s</div>
</div>
<div class="top-actions">
<span class="badge live">serveur local</span>
<span class="badge">127.0.0.1:7878</span>
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
</div>
</div>
<!-- ===== OVERVIEW ===== -->
<section class="view active" id="overview">
<div class="kpis">
<div class="kpi"><span class="emoji">🤖</span><div class="label">Agents installés</div><div class="value">9</div><div class="delta up">▲ +1 cette semaine</div></div>
<div class="kpi"><span class="emoji">⚡</span><div class="label">En cours</div><div class="value">2</div><div class="delta up">● claude-code · codex</div></div>
<div class="kpi"><span class="emoji">⏱</span><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 % vs semaine préc.</div></div>
<div class="kpi"><span class="emoji">📜</span><div class="label">Événements (24 h)</div><div class="value">132</div><div class="delta down">▼ 8 % vs hier</div></div>
</div>
<div class="grid2">
<div class="panel">
<h3>Top agents — sessions (7 j)</h3>
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
</div>
<div class="panel">
<h3>Activité — sessions par jour (14 j)</h3>
<div class="trend-wrap">
<svg viewBox="0 0 480 150" width="100%" height="150">
<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3ddc97" stop-opacity=".35"/><stop offset="1" stop-color="#3ddc97" stop-opacity="0"/></linearGradient></defs>
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g)"/>
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3ddc97" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="480" cy="34" r="4" fill="#38bdf8"/>
</svg>
<div class="trend-axis"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
</div>
</div>
</div>
<div class="grid2">
<div class="panel">
<h3>Activité récente</h3>
<ul class="tl">
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">claude-code</span> terminé — session 3 h 12 min</span><span class="m">il y a 2 min</span></li>
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">codex</span> démarré — projet flowdeck</span><span class="m">il y a 14 min</span></li>
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">jcode</span> mis à jour 0.76.0 → 0.78.1</span><span class="m">il y a 1 h</span></li>
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">aider</span> échec — exit code 1</span><span class="m">il y a 3 h</span></li>
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">gemini-cli</span> terminé — session 42 min</span><span class="m">il y a 5 h</span></li>
</ul>
</div>
<div class="panel">
<h3>Sessions en cours</h3>
<table>
<thead><tr><th>Agent</th><th>Projet</th><th>Durée</th><th>Démarrée</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>agent-manager</td><td class="mono">3 h 12 min</td><td class="mono">14:26</td></tr>
<tr><td>codex</td><td>flowdeck</td><td class="mono">14 min</td><td class="mono">17:18</td></tr>
</tbody>
</table>
<div class="empty-note" id="running-empty" style="display:none">Aucune session en cours</div>
</div>
</div>
</section>
<!-- ===== STATS ===== -->
<section class="view" id="stats">
<div class="panel">
<h3>Statistiques par agent — période 30 j</h3>
<table>
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
<tr><td>opencode</td><td>9</td><td class="mono">4 h 40 m</td><td><span class="pill fail">55 %</span></td><td class="mono">0,90 €</td><td class="mono">il y a 2 j</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== SESSIONS ===== -->
<section class="view" id="sessions">
<div class="panel">
<h3>Sessions — 7 derniers jours</h3>
<input class="search" id="sess-search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
<tbody id="sess-body">
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
<tr><td class="mono">20260817_183011_e2f3a4</td><td>jcode</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 22 min</td><td class="mono">19:35</td></tr>
</tbody>
</table>
<div class="empty-note" id="sess-empty" style="display:none">Aucune session ne correspond au filtre</div>
</div>
</section>
<!-- ===== EVENTS ===== -->
<section class="view" id="events">
<div class="panel">
<h3>Journal d'événements — aujourd'hui</h3>
<ul class="tl">
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> codex — projet flowdeck</span><span class="m">17:18</span></li>
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> claude-code — session 1 h 05 min</span><span class="m">16:40</span></li>
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> claude-code — projet agent-manager</span><span class="m">15:34</span></li>
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">run</span> aider — exit code 1 (fichier manquant)</span><span class="m">14:33</span></li>
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">update</span> jcode 0.76.0 → 0.78.1</span><span class="m">13:05</span></li>
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> gemini-cli — session 42 min</span><span class="m">12:12</span></li>
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> gemini-cli — projet imago</span><span class="m">11:30</span></li>
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">install</span> opencode 0.14.2 — méthode binary</span><span class="m">10:02</span></li>
</ul>
</div>
</section>
<!-- ===== PROJECTS ===== -->
<section class="view" id="projects">
<div class="kpis" style="grid-template-columns:repeat(3,1fr)">
<div class="kpi"><div class="label">Projets suivis</div><div class="value">4</div><div class="delta up">▲ 1 nouveau cette semaine</div></div>
<div class="kpi"><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 %</div></div>
<div class="kpi"><div class="label">Temps total</div><div class="value">96 h</div><div class="delta up">▲ +8 h vs semaine préc.</div></div>
</div>
<div class="panel">
<h3>Projets</h3>
<table>
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
</tbody>
</table>
</div>
</section>
</main>
</div>
<script>
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
const v = el.dataset.view;
document.getElementById(v).classList.add('active');
document.getElementById('view-title').textContent = titles[v];
});
});
function fakeRefresh(){
const t = new Date();
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + t.toLocaleTimeString('fr-FR');
}
function filterSessions(q){
q = q.toLowerCase();
let shown = 0;
document.querySelectorAll('#sess-body tr').forEach(tr => {
const ok = tr.textContent.toLowerCase().includes(q);
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
document.getElementById('sess-empty').style.display = shown ? 'none' : 'block';
}
</script>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am web — Admin clair (mockup)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#f6f7f9; --panel:#ffffff; --border:#e3e7ec; --fg:#1c2430; --muted:#64748b;
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --err:#dc2626; --purple:#7c3aed;
}
body {
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
}
/* header bar */
.head {
background:var(--panel); border-bottom:1px solid var(--border);
padding:0 28px; height:56px; display:flex; align-items:center; gap:24px; position:sticky; top:0; z-index:5;
}
.head .brand { font-weight:800; font-size:16px; display:flex; align-items:center; gap:9px; }
.brand .sq { width:26px; height:26px; border-radius:7px; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; }
.tabs { display:flex; gap:4px; height:100%; }
.tab { display:flex; align-items:center; padding:0 16px; color:var(--muted); cursor:pointer; font-size:13.5px; font-weight:500; border-bottom:2px solid transparent; }
.tab:hover { color:var(--fg); }
.tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.head .spacer { flex:1; }
.hbadge { font-size:11.5px; color:var(--muted); background:var(--bg); border:1px solid var(--border); padding:4px 10px; border-radius:99px; font-family:ui-monospace,Consolas,monospace; }
.hbadge.ok { color:var(--ok); }
.btn { background:var(--accent); color:#fff; border:none; border-radius:7px; padding:7px 14px; font-weight:600; font-size:13px; cursor:pointer; }
.btn:hover { background:#1d4ed8; }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
.btn.ghost:hover { color:var(--fg); }
.wrap { max-width:1150px; margin:26px auto; padding:0 28px; }
.page-title { font-size:19px; font-weight:700; margin-bottom:4px; }
.page-sub { color:var(--muted); font-size:12.5px; margin-bottom:20px; }
.cards { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.card { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:15px 17px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
.card .l { color:var(--muted); font-size:11.5px; text-transform:uppercase; letter-spacing:.5px; }
.card .v { font-size:26px; font-weight:800; margin-top:5px; color:#111827; }
.card .d { font-size:11.5px; margin-top:3px; } .card .d.up{color:var(--ok);} .card .d.down{color:var(--err);}
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px 18px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
.panel h3 { font-size:12.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--muted); margin-bottom:14px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.4px; padding:7px 10px; border-bottom:1px solid var(--border); background:var(--bg); }
td { padding:9px 10px; border-bottom:1px solid var(--border); }
tbody tr:hover { background:#fafbfc; }
.pill { font-size:10.5px; padding:2px 9px; border-radius:99px; font-weight:600; }
.pill.ok { background:#dcfce7; color:var(--ok); } .pill.run { background:#dbeafe; color:#1d4ed8; }
.pill.fail { background:#fee2e2; color:var(--err); } .pill.mut { background:#ede9fe; color:var(--purple); }
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
.bar-row .name { width:110px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bar-row .track { flex:1; height:12px; background:#eef1f5; border-radius:6px; overflow:hidden; }
.bar-row .fill { height:100%; background:var(--accent); border-radius:6px; }
.bar-row .val { width:32px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
.search { width:100%; border:1px solid var(--border); border-radius:7px; padding:8px 12px; font-size:13px; margin-bottom:10px; background:#fff; }
.search:focus { outline:none; border-color:var(--accent); }
.view { display:none; } .view.active { display:block; }
@media (max-width:900px){ .cards{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} }
</style>
</head>
<body>
<div class="head">
<div class="brand"><span class="sq">am</span> agent-manager</div>
<div class="tabs">
<div class="tab active" data-view="overview">Vue d'ensemble</div>
<div class="tab" data-view="stats">Statistiques</div>
<div class="tab" data-view="sessions">Sessions</div>
<div class="tab" data-view="events">Événements</div>
<div class="tab" data-view="projects">Projets</div>
</div>
<div class="spacer"></div>
<span class="hbadge ok">● local</span>
<span class="hbadge">127.0.0.1:7878 · v0.6.0</span>
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
</div>
<div class="wrap">
<div class="page-title" id="view-title">Vue d'ensemble</div>
<div class="page-sub" id="last-update">Dernière mise à jour : il y a 5 s · lang FR</div>
<section class="view active" id="overview">
<div class="cards">
<div class="card"><div class="l">Agents installés</div><div class="v">9</div><div class="d up">▲ +1 cette semaine</div></div>
<div class="card"><div class="l">En cours</div><div class="v">2</div><div class="d up">● claude-code · codex</div></div>
<div class="card"><div class="l">Sessions (7 j)</div><div class="v">47</div><div class="d up">▲ +12 %</div></div>
<div class="card"><div class="l">Événements (24 h)</div><div class="v">132</div><div class="d down">▼ 8 % vs hier</div></div>
</div>
<div class="grid2">
<div class="panel">
<h3>Top agents — sessions (7 j)</h3>
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
</div>
<div class="panel">
<h3>Activité — sessions par jour (14 j)</h3>
<svg viewBox="0 0 480 150" width="100%" height="150">
<defs><linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2563eb" stop-opacity=".2"/><stop offset="1" stop-color="#2563eb" stop-opacity="0"/></linearGradient></defs>
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g2)"/>
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10.5px;font-family:ui-monospace,monospace;margin-top:4px"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
</div>
</div>
<div class="panel">
<h3>Sessions — 7 derniers jours</h3>
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
<tbody id="sess-body-ov">
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="stats">
<div class="panel">
<h3>Statistiques par agent — période 30 j</h3>
<table>
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="sessions">
<div class="panel">
<h3>Sessions — 7 derniers jours</h3>
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
<tbody id="sess-body">
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="events">
<div class="panel">
<h3>Journal d'événements — aujourd'hui</h3>
<table>
<thead><tr><th>Heure</th><th>Type</th><th>Agent</th><th>Détail</th></tr></thead>
<tbody>
<tr><td class="mono">17:18</td><td><span class="pill run">start</span></td><td>codex</td><td>projet flowdeck</td></tr>
<tr><td class="mono">16:40</td><td><span class="pill ok">stop</span></td><td>claude-code</td><td>session 1 h 05 min</td></tr>
<tr><td class="mono">15:34</td><td><span class="pill run">start</span></td><td>claude-code</td><td>projet agent-manager</td></tr>
<tr><td class="mono">14:33</td><td><span class="pill fail">run</span></td><td>aider</td><td>exit code 1</td></tr>
<tr><td class="mono">13:05</td><td><span class="pill mut">update</span></td><td>jcode</td><td>0.76.0 → 0.78.1</td></tr>
<tr><td class="mono">10:02</td><td><span class="pill mut">install</span></td><td>opencode</td><td>0.14.2 — méthode binary</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="projects">
<div class="panel">
<h3>Projets</h3>
<table>
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<script>
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
document.querySelectorAll('.tab').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
document.getElementById(el.dataset.view).classList.add('active');
document.getElementById('view-title').textContent = titles[el.dataset.view];
});
});
function fakeRefresh(){
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + new Date().toLocaleTimeString('fr-FR') + ' · lang FR';
}
function filterSessions(q){
q = q.toLowerCase();
document.querySelectorAll('#sess-body tr, #sess-body-ov tr').forEach(tr => {
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
</script>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am web — Terminal dense (mockup)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0d1117; --panel:#10161f; --border:#212a36; --fg:#c9d4e0; --muted:#6e7b8a;
--accent:#3fb950; --accent2:#58a6ff; --ok:#3fb950; --warn:#d29922; --err:#f85149; --purple:#bc8cff;
}
body {
font-family:ui-monospace,SFMono-Regular,Consolas,"Cascadia Mono",Menlo,monospace;
background:var(--bg); color:var(--fg); font-size:12.5px; line-height:1.5;
}
.top {
border-bottom:1px solid var(--border); padding:9px 16px; display:flex; align-items:center; gap:16px;
background:var(--panel); position:sticky; top:0; z-index:5;
}
.top .prompt { color:var(--accent); font-weight:700; }
.top .brand { color:var(--fg); }
.tabs { display:flex; gap:2px; }
.tab { padding:3px 10px; cursor:pointer; color:var(--muted); border:1px solid transparent; border-radius:5px; }
.tab:hover { color:var(--fg); }
.tab.active { color:var(--accent); background:rgba(63,185,80,.08); border-color:rgba(63,185,80,.3); }
.top .spacer { flex:1; }
.stat { color:var(--muted); font-size:11.5px; }
.stat b { color:var(--ok); font-weight:400; }
.btn { background:transparent; border:1px solid var(--border); color:var(--fg); border-radius:5px; padding:3px 10px; font-family:inherit; font-size:12px; cursor:pointer; }
.btn:hover { border-color:var(--accent); color:var(--accent); }
.wrap { max-width:1180px; margin:0 auto; padding:14px 16px 40px; }
/* overview row */
.kv { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:14px; }
.kv-item { border:1px solid var(--border); background:var(--panel); border-radius:6px; padding:9px 12px; }
.kv-item .k { color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; }
.kv-item .v { font-size:21px; font-weight:700; color:var(--fg); margin-top:2px; }
.kv-item .v small { color:var(--muted); font-size:11px; font-weight:400; }
.kv-item.running .v { color:var(--accent); }
.kv-item.running .v::before { content:"● "; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:14px; }
.box { border:1px solid var(--border); background:var(--panel); border-radius:6px; overflow:hidden; }
.box .h { padding:7px 12px; border-bottom:1px solid var(--border); color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; display:flex; justify-content:space-between; }
.box .h .n { color:var(--accent); text-transform:none; letter-spacing:0; }
.box .b { padding:10px 12px; }
/* bars */
.br { display:grid; grid-template-columns:95px 1fr 30px; gap:8px; align-items:center; margin-bottom:6px; }
.br .nm { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.br .tr { height:10px; background:var(--bg); border-radius:3px; overflow:hidden; }
.br .fl { height:100%; background:var(--accent); opacity:.85; }
.br .vl { text-align:right; color:var(--muted); }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; color:var(--muted); font-weight:400; font-size:10.5px; text-transform:uppercase; letter-spacing:.6px; padding:5px 10px; border-bottom:1px solid var(--border); }
td { padding:5px 10px; border-bottom:1px solid rgba(33,42,54,.6); white-space:nowrap; }
td.wrap { white-space:normal; }
tbody tr:hover { background:rgba(63,185,80,.05); }
.st { font-size:11px; } .st::before { content:"● "; }
.st.ok { color:var(--ok); } .st.run { color:var(--accent2); } .st.fail { color:var(--err); } .st.mut { color:var(--purple); }
.dim { color:var(--muted); }
.view { display:none; } .view.active { display:block; }
@media (max-width:900px){ .row{grid-template-columns:1fr;} .kv{grid-template-columns:repeat(2,1fr);} }
</style>
</head>
<body>
<div class="top">
<span class="prompt">$</span><span class="brand">am web</span>
<div class="tabs">
<div class="tab active" data-view="overview">overview</div>
<div class="tab" data-view="stats">stats</div>
<div class="tab" data-view="sessions">sessions</div>
<div class="tab" data-view="events">events</div>
<div class="tab" data-view="projects">projects</div>
</div>
<div class="spacer"></div>
<span class="stat">v0.6.0 · <b>●</b> local · 127.0.0.1:7878</span>
<button class="btn" onclick="fakeRefresh()">↻ refresh</button>
</div>
<div class="wrap">
<section class="view active" id="overview">
<div class="kv">
<div class="kv-item"><div class="k">agents installés</div><div class="v">9</div></div>
<div class="kv-item running"><div class="k">en cours</div><div class="v">2</div></div>
<div class="kv-item"><div class="k">sessions 7j</div><div class="v">47 <small>▲12%</small></div></div>
<div class="kv-item"><div class="k">events 24h</div><div class="v">132 <small>▼8%</small></div></div>
</div>
<div class="row">
<div class="box">
<div class="h">top agents · sessions 7j</div>
<div class="b">
<div class="br"><span class="nm">claude-code</span><div class="tr"><div class="fl" style="width:100%"></div></div><span class="vl">18</span></div>
<div class="br"><span class="nm">aider</span><div class="tr"><div class="fl" style="width:50%"></div></div><span class="vl">9</span></div>
<div class="br"><span class="nm">codex</span><div class="tr"><div class="fl" style="width:44%"></div></div><span class="vl">8</span></div>
<div class="br"><span class="nm">gemini-cli</span><div class="tr"><div class="fl" style="width:28%"></div></div><span class="vl">5</span></div>
<div class="br"><span class="nm">jcode</span><div class="tr"><div class="fl" style="width:22%"></div></div><span class="vl">4</span></div>
<div class="br"><span class="nm">opencode</span><div class="tr"><div class="fl" style="width:17%"></div></div><span class="vl">3</span></div>
</div>
</div>
<div class="box">
<div class="h">activité 14j <span class="n">sessions/jour</span></div>
<div class="b">
<svg viewBox="0 0 480 150" width="100%" height="140">
<defs><linearGradient id="g3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3fb950" stop-opacity=".3"/><stop offset="1" stop-color="#3fb950" stop-opacity="0"/></linearGradient></defs>
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g3)"/>
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3fb950" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10px"><span>04/08</span><span>11/08</span><span>18/08</span></div>
</div>
</div>
</div>
<div class="box">
<div class="h">sessions en cours</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>durée</th><th>depuis</th></tr></thead>
<tbody>
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td>3h12m</td><td class="dim">14:26</td></tr>
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td>14m</td><td class="dim">17:18</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="stats">
<div class="box">
<div class="h">stats par agent · 30j</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>agent</th><th>sessions</th><th>durée</th><th>succès</th><th>coût</th><th>dernière act.</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>54</td><td>38h12m</td><td><span class="st ok">94%</span></td><td class="dim">12,40 €</td><td class="dim">2 min</td></tr>
<tr><td>aider</td><td>31</td><td>21h48m</td><td><span class="st ok">87%</span></td><td class="dim">4,10 €</td><td class="dim">3 h</td></tr>
<tr><td>codex</td><td>27</td><td>19h05m</td><td><span class="st ok">89%</span></td><td class="dim">8,75 €</td><td class="dim">14 min</td></tr>
<tr><td>gemini-cli</td><td>14</td><td>6h33m</td><td><span class="st ok">79%</span></td><td class="dim">0 € local</td><td class="dim">5 h</td></tr>
<tr><td>jcode</td><td>12</td><td>5h02m</td><td><span class="st run">67%</span></td><td class="dim">1,20 €</td><td class="dim">hier</td></tr>
<tr><td>opencode</td><td>9</td><td>4h40m</td><td><span class="st fail">55%</span></td><td class="dim">0,90 €</td><td class="dim">2 j</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="sessions">
<div class="box">
<div class="h">sessions · 7j</div>
<div class="b" style="padding:8px 12px 0">
<input id="sess-search" placeholder="$ grep sessions…" style="width:100%;background:var(--bg);border:1px solid var(--border);color:var(--fg);font-family:inherit;font-size:12px;padding:6px 10px;border-radius:4px;margin-bottom:6px" oninput="filterSessions(this.value)">
</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>statut</th><th>durée</th><th>fin</th></tr></thead>
<tbody id="sess-body">
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="st run">running</span></td><td>3h12m</td><td class="dim">—</td></tr>
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="st run">running</span></td><td>14m</td><td class="dim">—</td></tr>
<tr><td class="dim">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>1h05m</td><td class="dim">16:40</td></tr>
<tr><td class="dim">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="st fail">failed</span></td><td>12m</td><td class="dim">14:33</td></tr>
<tr><td class="dim">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="st ok">done</span></td><td>42m</td><td class="dim">12:12</td></tr>
<tr><td class="dim">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>2h47m</td><td class="dim">23:50</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="events">
<div class="box">
<div class="h">events · aujourd'hui</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>heure</th><th>type</th><th>agent</th><th class="wrap">détail</th></tr></thead>
<tbody>
<tr><td class="dim">17:18</td><td><span class="st run">start</span></td><td>codex</td><td class="wrap">projet flowdeck</td></tr>
<tr><td class="dim">16:40</td><td><span class="st ok">stop</span></td><td>claude-code</td><td class="wrap">session 1h05m</td></tr>
<tr><td class="dim">15:34</td><td><span class="st run">start</span></td><td>claude-code</td><td class="wrap">projet agent-manager</td></tr>
<tr><td class="dim">14:33</td><td><span class="st fail">run</span></td><td>aider</td><td class="wrap">exit code 1 — fichier manquant</td></tr>
<tr><td class="dim">13:05</td><td><span class="st mut">update</span></td><td>jcode</td><td class="wrap">0.76.0 → 0.78.1</td></tr>
<tr><td class="dim">10:02</td><td><span class="st mut">install</span></td><td>opencode</td><td class="wrap">0.14.2 — méthode binary</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="projects">
<div class="box">
<div class="h">projects</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>projet</th><th>agents</th><th>sessions 7j</th><th>temps</th><th>dernière act.</th></tr></thead>
<tbody>
<tr><td>agent-manager <span class="dim">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="dim">41h</td><td class="dim">2 min</td></tr>
<tr><td>flowdeck <span class="dim">· Python</span></td><td>codex · aider</td><td>11</td><td class="dim">19h</td><td class="dim">14 min</td></tr>
<tr><td>obsigate <span class="dim">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="dim">22h</td><td class="dim">3 h</td></tr>
<tr><td>imago <span class="dim">· Python</span></td><td>gemini-cli</td><td>4</td><td class="dim">14h</td><td class="dim">5 h</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<script>
document.querySelectorAll('.tab').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
document.getElementById(el.dataset.view).classList.add('active');
});
});
function fakeRefresh(){}
function filterSessions(q){
q = q.toLowerCase();
document.querySelectorAll('#sess-body tr').forEach(tr => {
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
</script>
</body>
</html>
+622
View File
@@ -0,0 +1,622 @@
//! Post-install agent configuration (issue #91): the `config:` block of an
//! agent definition wires the resolved provider/model into the agent —
//! the environment variables it expects (env_map) and the config files to
//! write or softly edit (files).
//!
//! Pure functions + per-format tests (probe.rs pattern). Security rule: a
//! token is never written in clear — the api_key slot stays the `@secret`
//! reference (issue #89), resolved from the OS keyring at start/run time.
use crate::app::App;
use crate::config::{AgentDef, Config};
use anyhow::{anyhow, Context, Result};
use std::collections::BTreeMap;
use std::path::Path;
/// The four format families covering ~every agent (spec #91).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
Toml,
Yaml,
Json,
KeyValue,
}
impl FileFormat {
pub fn name(self) -> &'static str {
match self {
FileFormat::Toml => "toml",
FileFormat::Yaml => "yaml",
FileFormat::Json => "json",
FileFormat::KeyValue => "key=value",
}
}
}
/// Detect the format family from the file extension.
pub fn format_of(path: &Path) -> Option<FileFormat> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"toml" => Some(FileFormat::Toml),
"yaml" | "yml" => Some(FileFormat::Yaml),
"json" => Some(FileFormat::Json),
"conf" | "ini" | "env" | "properties" | "cfg" => Some(FileFormat::KeyValue),
_ => None,
}
}
/// Provider preferred by an agent: `provider:` of the definition, then the
/// `config.provider_default` of its config block (issue #91).
pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
agent
.provider
.as_deref()
.or(agent.config.as_ref().and_then(|c| c.provider_default.as_deref()))
}
/// Merge the resolved `config.env_map` of an agent into its default env
/// (issue #91): api_key -> `@secret` (resolved by resolve_env_secrets),
/// model/base_url/provider -> values of the resolved provider. Explicit
/// values already present in agent.env are never overwritten.
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
apply_env_map_resolved(env, agent, config, None, None);
}
/// Same as `apply_env_map` but with the provider and model fixed by the
/// caller (issue #92: `am run/start --provider/--model`): the flags win
/// over the agent declaration and the settings default.
pub fn apply_env_map_resolved(
env: &mut BTreeMap<String, String>,
agent: &AgentDef,
config: &Config,
provider_override: Option<&str>,
model_override: Option<&str>,
) {
let Some(cfg) = &agent.config else {
return;
};
if cfg.env_map.is_empty() {
return;
}
let Some(pname) = provider_override
.filter(|p| !p.is_empty())
.or_else(|| provider_pref(agent))
.or_else(|| crate::providers::default_name(config))
else {
return;
};
let Some(def) = crate::providers::get(config, pname) else {
return;
};
let model = model_override
.filter(|m| !m.is_empty())
.or(agent.model.as_deref())
.or(def.default_model.as_deref());
for (slot, var) in &cfg.env_map {
let value = match slot.as_str() {
"api_key" => "@secret".to_string(),
"model" => {
let Some(m) = model else { continue };
m.to_string()
}
"base_url" => def.base_url.clone(),
"provider" => pname.to_string(),
_ => continue, // unknown slots are ignored
};
env.entry(var.clone()).or_insert(value);
}
}
/// Substitute the {model}, {provider} and {base_url} placeholders of a
/// setup hint with the resolved provider configuration (v1.1.4). When no
/// provider is resolved, the hint is returned unchanged.
pub fn render_hint(
hint: &str,
resolved: &Option<(String, Option<String>)>,
config: &Config,
) -> String {
let Some((pname, model)) = resolved else {
return hint.to_string();
};
let base_url = crate::providers::get(config, pname)
.map(|d| d.base_url.clone())
.unwrap_or_default();
hint.replace("{provider}", pname)
.replace(
"{model}",
model.as_deref().unwrap_or("{model}"),
)
.replace("{base_url}", &base_url)
}
/// Print the manual setup commands of an agent (agents that manage their
/// own configuration, e.g. picoclaw onboard/model/auth). No-op when the
/// agent declares none (v1.1.4).
fn print_hints(
app: &App,
agent: &AgentDef,
resolved: &Option<(String, Option<String>)>,
) {
if agent.setup_hints.is_empty() {
return;
}
app.log
.success(crate::i18n::tr("Configuration manuelle requise — à exécuter :"));
for hint in &agent.setup_hints {
app.log.info(&format!(" {}", render_hint(hint, resolved, &app.config)));
}
}
/// Write the agent configuration files after a successful install
/// (issue #91). The caller skips this on `--no-config`, on non-configurable
/// agents, and in dry-run (never reached). Never writes a token in clear:
/// the api_key slot is written as the `@secret` reference.
pub fn apply_post_install(
app: &App,
agent: &AgentDef,
resolved: &Option<(String, Option<String>)>,
) -> Result<()> {
let Some(cfg) = &agent.config else {
app.log.info(crate::i18n::tr(
"agent sans adaptateur de config — voir sa doc",
));
print_hints(app, agent, resolved);
return Ok(());
};
if cfg.files.is_empty() {
if cfg.env_map.is_empty() {
app.log.info(crate::i18n::tr(
"agent sans adaptateur de config — voir sa doc",
));
} else {
app.log.info(crate::i18n::tr(
"config par variables d'env — injectée au démarrage",
));
}
print_hints(app, agent, resolved);
return Ok(());
}
let Some((pname, model)) = resolved else {
app.log.warn(crate::i18n::tr(
"aucun provider résolu — fichiers de config non écrits",
));
print_hints(app, agent, resolved);
return Ok(());
};
let Some(def) = crate::providers::get(&app.config, pname) else {
app.log.warn(&crate::tr_fmt!(
"provider '{}' introuvable — fichiers de config non écrits",
pname
));
return Ok(());
};
for f in &cfg.files {
let path = crate::config::expand_path(&f.path);
let Some(fmt) = format_of(&path) else {
app.log.warn(&crate::tr_fmt!(
"format inconnu pour {} — adaptateurs: toml, yaml, json, key=value",
path.display()
));
continue;
};
let mut updates: Vec<(String, String)> = Vec::new();
for key in &f.keys {
let value = match key.as_str() {
"api_key" => "@secret".to_string(),
"model" => model.clone().unwrap_or_default(),
"base_url" => def.base_url.clone(),
"provider" => pname.clone(),
other => {
app.log.warn(&crate::tr_fmt!(
"clé '{}' inconnue dans config.files de {}",
other,
agent.name
));
continue;
}
};
updates.push((key.clone(), value));
}
write_soft(&path, fmt, &updates)?;
app.log
.success(&crate::tr_fmt!("config écrite: {}", path.display()));
}
Ok(())
}
/// Soft edit of a config file: existing unknown keys are preserved, known
/// keys are updated, missing keys are appended. Creates parent dirs.
pub fn write_soft(path: &Path, format: FileFormat, updates: &[(String, String)]) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
}
let text = if path.exists() {
std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?
} else {
String::new()
};
let out = match format {
FileFormat::Toml => edit_toml(&text, updates)?,
FileFormat::Yaml => edit_yaml(&text, updates)?,
FileFormat::Json => edit_json(&text, updates)?,
FileFormat::KeyValue => edit_key_value(&text, updates),
};
std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
/// TOML soft edit. Note: toml::Value does not retain comments on
/// serialization — unknown KEYS are preserved, comments are dropped.
fn edit_toml(text: &str, updates: &[(String, String)]) -> Result<String> {
let mut root: toml::Value = if text.trim().is_empty() {
toml::Value::Table(Default::default())
} else {
text.parse::<toml::Value>()
.map_err(|e| anyhow!("invalid TOML: {e}"))?
};
let table = root
.as_table_mut()
.ok_or_else(|| anyhow!("TOML root is not a table"))?;
for (k, v) in updates {
table.insert(k.clone(), toml::Value::String(v.clone()));
}
Ok(root.to_string())
}
fn edit_yaml(text: &str, updates: &[(String, String)]) -> Result<String> {
let mut root: serde_yaml::Value = if text.trim().is_empty() {
serde_yaml::Value::Mapping(Default::default())
} else {
serde_yaml::from_str(text).map_err(|e| anyhow!("invalid YAML: {e}"))?
};
let map = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("YAML root is not a mapping"))?;
for (k, v) in updates {
map.insert(
serde_yaml::Value::String(k.clone()),
serde_yaml::Value::String(v.clone()),
);
}
serde_yaml::to_string(&root).map_err(|e| anyhow!("cannot serialize YAML: {e}"))
}
fn edit_json(text: &str, updates: &[(String, String)]) -> Result<String> {
let mut root: serde_json::Value = if text.trim().is_empty() {
serde_json::Value::Object(Default::default())
} else {
serde_json::from_str(text).map_err(|e| anyhow!("invalid JSON: {e}"))?
};
let obj = root
.as_object_mut()
.ok_or_else(|| anyhow!("JSON root is not an object"))?;
for (k, v) in updates {
obj.insert(k.clone(), serde_json::Value::String(v.clone()));
}
serde_json::to_string_pretty(&root).map_err(|e| anyhow!("cannot serialize JSON: {e}"))
}
fn edit_key_value(text: &str, updates: &[(String, String)]) -> String {
let mut lines: Vec<String> = text.lines().map(String::from).collect();
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for (i, line) in lines.iter_mut().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.contains('=')
{
continue;
}
let key = trimmed.split('=').next().unwrap_or("").trim();
// Case-insensitive match (API_KEY vs api_key in .env files); the
// existing line keeps its casing.
if let Some((k, v)) = updates.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
*line = format!("{key}={v}");
seen.insert(k, i);
}
}
for (k, v) in updates {
if !seen.contains_key(k.as_str()) {
lines.push(format!("{k}={v}"));
}
}
let mut out = lines.join("\n");
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use crate::config::{AgentConfig, Config};
use std::collections::BTreeMap;
fn updates() -> Vec<(String, String)> {
vec![
("model".to_string(), "gpt-5.2".to_string()),
("base_url".to_string(), "https://api.openai.com/v1".to_string()),
("api_key".to_string(), "@secret".to_string()),
]
}
fn tmp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(name);
if !content.is_empty() {
std::fs::write(&p, content).unwrap();
}
(dir, p)
}
#[test]
fn format_detection_covers_the_four_families() {
assert_eq!(format_of(Path::new("a.toml")), Some(FileFormat::Toml));
assert_eq!(format_of(Path::new("a.yaml")), Some(FileFormat::Yaml));
assert_eq!(format_of(Path::new("a.yml")), Some(FileFormat::Yaml));
assert_eq!(format_of(Path::new("a.json")), Some(FileFormat::Json));
assert_eq!(format_of(Path::new("a.conf")), Some(FileFormat::KeyValue));
assert_eq!(format_of(Path::new("a.env")), Some(FileFormat::KeyValue));
assert_eq!(format_of(Path::new("a.ini")), Some(FileFormat::KeyValue));
assert_eq!(format_of(Path::new("a.md")), None);
}
#[test]
fn toml_round_trip_keeps_unknown_keys() {
let (_d, p) = tmp("c.toml", "# existing\nmodel = \"old\"\ntemperature = 0.7\n");
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
assert!(
out.contains("base_url = \"https://api.openai.com/v1\""),
"{out}"
);
assert!(out.contains("api_key = \"@secret\""), "{out}");
assert!(out.contains("temperature = 0.7"), "unknown key lost: {out}");
}
#[test]
fn toml_creates_the_file_when_missing() {
let (_d, p) = tmp("new.toml", "");
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
assert!(out.contains("api_key = \"@secret\""), "{out}");
}
#[test]
fn yaml_round_trip_keeps_unknown_keys() {
let (_d, p) = tmp("c.yaml", "model: old\nverbose: true\n");
write_soft(&p, FileFormat::Yaml, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model: gpt-5.2"), "{out}");
assert!(out.contains("base_url: https://api.openai.com/v1"), "{out}");
assert!(out.contains("verbose: true"), "unknown key lost: {out}");
}
#[test]
fn json_round_trip_keeps_unknown_keys() {
let (_d, p) = tmp("c.json", "{\"model\": \"old\", \"verbose\": true}");
write_soft(&p, FileFormat::Json, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("\"model\": \"gpt-5.2\""), "{out}");
assert!(out.contains("\"api_key\": \"@secret\""), "{out}");
assert!(out.contains("\"verbose\": true"), "unknown key lost: {out}");
}
#[test]
fn key_value_round_trip_keeps_unknown_lines() {
let (_d, p) = tmp(
"c.conf",
"# header\nMODEL=old\nOTHER=keep\n",
);
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("MODEL=gpt-5.2"), "{out}");
assert!(out.contains("OTHER=keep"), "unknown line lost: {out}");
assert!(out.contains("# header"), "comment lost: {out}");
assert!(out.contains("api_key=@secret"), "{out}");
// No duplicate MODEL line.
assert_eq!(out.matches("MODEL=").count(), 1, "{out}");
}
#[test]
fn key_value_appends_missing_keys() {
let (_d, p) = tmp("empty.env", "");
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model=gpt-5.2"), "{out}");
assert!(out.contains("base_url=https://api.openai.com/v1"), "{out}");
assert!(out.contains("api_key=@secret"), "{out}");
}
fn agent_with_config(config: AgentConfig, provider: Option<String>) -> AgentDef {
AgentDef {
name: "x".to_string(),
display_name: None,
description: None,
category: None,
website: None,
install: None,
dependencies: vec![],
run: Some("x".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: None,
model_arg: None,
provider,
model: None,
config: Some(config),
setup_hints: vec![],
sandbox: None,
tags: vec![],
installable: false,
note: None,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
#[test]
fn apply_env_map_wires_the_resolved_provider() {
let mut c: Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n",
)
.unwrap();
let mut env_map = BTreeMap::new();
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
env_map.insert("model".to_string(), "MY_MODEL".to_string());
env_map.insert("base_url".to_string(), "MY_URL".to_string());
let agent = agent_with_config(
AgentConfig {
env_map,
files: vec![],
provider_default: None,
},
None,
);
let mut env = BTreeMap::new();
apply_env_map(&mut env, &agent, &c);
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
// Explicit agent.env values win.
let mut env2 = BTreeMap::new();
env2.insert("MY_MODEL".to_string(), "explicit".to_string());
apply_env_map(&mut env2, &agent, &c);
assert_eq!(env2.get("MY_MODEL").unwrap(), "explicit");
// Unset default provider + no provider_default: no-op.
c.settings.default_provider = None;
let mut env3 = BTreeMap::new();
apply_env_map(&mut env3, &agent, &c);
assert!(env3.is_empty());
}
#[test]
fn apply_post_install_without_config_block_is_a_noop() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app = crate::app::App::from_cli(cli).unwrap();
let agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: None,
},
None,
);
// No config block at all → Ok, nothing written.
let mut bare = agent.clone();
bare.config = None;
assert!(apply_post_install(&app, &bare, &None).is_ok());
// Config block but no files → Ok, nothing written.
assert!(apply_post_install(&app, &agent, &None).is_ok());
}
#[test]
fn provider_pref_prefers_the_declared_provider() {
let agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: Some("openai".to_string()),
},
Some("anthropic".to_string()),
);
assert_eq!(provider_pref(&agent), Some("anthropic"));
let agent2 = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: Some("openai".to_string()),
},
None,
);
assert_eq!(provider_pref(&agent2), Some("openai"));
}
#[test]
fn render_hint_substitutes_resolved_values() {
let c: Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n default_provider: openrouter\n providers:\n openrouter:\n base_url: https://openrouter.ai/api/v1\n default_model: deepseek/deepseek-chat-v3-0324\n models: [deepseek/deepseek-chat-v3-0324]\n",
)
.unwrap();
let resolved = Some(("openrouter".to_string(), Some("deepseek/deepseek-chat-v3-0324".to_string())));
assert_eq!(
render_hint("picoclaw model {model}", &resolved, &c),
"picoclaw model deepseek/deepseek-chat-v3-0324"
);
assert_eq!(render_hint("picoclaw onboard", &resolved, &c), "picoclaw onboard");
assert_eq!(
render_hint("cli --provider {provider} --url {base_url}", &resolved, &c),
"cli --provider openrouter --url https://openrouter.ai/api/v1"
);
// No resolved provider: the hint is left untouched.
assert_eq!(
render_hint("picoclaw model {model}", &None, &c),
"picoclaw model {model}"
);
// Resolved provider without a model: {model} stays visible.
let no_model = Some(("openrouter".to_string(), None));
assert_eq!(
render_hint("picoclaw model {model}", &no_model, &c),
"picoclaw model {model}"
);
}
#[test]
fn apply_post_install_with_setup_hints_is_ok() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app = crate::app::App::from_cli(cli).unwrap();
let mut agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: None,
},
None,
);
agent.config = None;
agent.setup_hints = vec![
"picoclaw onboard".to_string(),
"picoclaw model {model}".to_string(),
];
// No provider resolved: hints printed with placeholders, no crash.
assert!(apply_post_install(&app, &agent, &None).is_ok());
assert!(apply_post_install(&app, &agent, &Some(("openrouter".to_string(), None))).is_ok());
}
}
+23 -1
View File
@@ -21,6 +21,9 @@ pub struct App {
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
/// Active output language (issue #74); mirrored in i18n::active() so the
/// REPL 'lang' command can switch it at runtime.
lang: Cell<crate::i18n::Lang>,
/// Active output color theme (switchable at runtime with the REPL
/// 'theme <name>' command).
theme: Cell<&'static Theme>,
@@ -84,6 +87,8 @@ impl App {
loaded.config.groups.clone(),
);
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
let lang = crate::i18n::resolve(cli.lang);
crate::i18n::set_lang(lang);
Ok(Self {
cli,
config: loaded.config,
@@ -92,6 +97,7 @@ impl App {
catalog,
state,
log,
lang: Cell::new(lang),
theme: Cell::new(theme),
})
}
@@ -116,6 +122,18 @@ impl App {
self.theme.get()
}
/// Active output language (issue #74).
pub fn lang(&self) -> crate::i18n::Lang {
self.lang.get()
}
/// Switch the active language (REPL 'lang' command); the i18n global is
/// kept in sync so tr()/tr_fmt! follow immediately.
pub fn set_lang(&self, lang: crate::i18n::Lang) {
self.lang.set(lang);
crate::i18n::set_lang(lang);
}
/// Switch the active theme (used by the REPL 'theme <name>' command).
pub fn set_theme(&self, theme: &'static Theme) {
self.theme.set(theme);
@@ -131,11 +149,15 @@ impl App {
}
/// Append an event to the journal; failures are never fatal (logged at
/// verbose level only).
/// verbose level only). The plugins subscribed to the event kind are
/// then fired (issue #75) — non-blocking, never fatal either.
pub fn emit(&self, event: &crate::events::Event) {
if let Err(e) = crate::events::append(&self.events_dir(), event) {
self.log.verbose(&format!("cannot write event journal: {e:#}"));
}
// Issue #76: aggregated anonymous counters (no-op while disabled).
crate::telemetry::maybe_record(self, event);
crate::plugins::dispatch(self, event);
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
+540
View File
@@ -0,0 +1,540 @@
//! am ask — natural language → am commands (issue #78).
//!
//! A local rule-based translator (offline, zero dependency, always works),
//! an optional LLM refinement through the provider registry (settings.ask),
//! and a translation cache for offline reuse. Every execution is confirmed
//! before it runs, and no journal data is ever sent to the provider — only
//! the user's own query.
use crate::app::App;
use crate::commands::execute_command;
use crate::config::AskSettings;
use crate::secrets::SecretStore;
use anyhow::{anyhow, Result};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// System prompt of the refinement call: the model must answer with one am
/// command per line, nothing else.
const SYSTEM_PROMPT: &str = "You translate a user request into commands of the 'am' CLI \
(agent-manager). Reply with ONE 'am' command per line — no text, no backticks, no \
explanation. Known commands: install, uninstall, update, start, stop, restart, run, \
list, status, ps, logs, history, stats, top, report, doctor, config, providers, \
secret, suggest, search, info, open, watch, sync, web, monitor, schedule, ask. \
Examples: «installe claude et lance-le» -> install claude-code\nstart claude-code ; \
«qui tourne ?» -> ps.";
const CACHE_MAX_ENTRIES: usize = 200;
// ---------------------------------------------------------------------------
// Rules (offline, bilingual FR/EN)
// ---------------------------------------------------------------------------
/// Known agent nicknames resolved before scanning the catalog.
const NICKNAMES: &[(&str, &str)] = &[
("claude", "claude-code"),
("claude code", "claude-code"),
("codex", "codex"),
("agentty", "agentty"),
("dsh", "deepseek-harness"),
("deepseek", "deepseek-harness"),
("opencode", "opencode"),
("aider", "aider"),
("gptme", "gptme"),
("goose", "goose"),
("openclaw", "openclaw"),
("jcode", "jcode"),
];
fn agent_of(part: &str, app: &App, prev: Option<&str>) -> Option<String> {
// Pronouns reuse the previous agent of the sentence.
let p = part.trim().to_lowercase();
let pronouns = [
"-le", "-la", "-les", "-l", "le", "la", "les", "l'", "it", "him", "her", "them", "celui-ci",
"celui la", "celui-là", "the agent", "l'agent",
];
if pronouns.iter().any(|pr| p.contains(pr)) {
if let Some(prev) = prev {
return Some(prev.to_string());
}
}
// Nicknames first (claude before scanning for "claude-code").
for (nick, target) in NICKNAMES {
if p.contains(nick) {
return Some(target.to_string());
}
}
// Exact catalog names, longest first so "claude-code" wins over "claude".
let mut names: Vec<&str> = app.config.agents.iter().map(|a| a.name.as_str()).collect();
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
for name in names {
if p.contains(name) {
return Some(name.to_string());
}
}
None
}
fn translate_part(part: &str, app: &App, prev: &mut Option<String>) -> Option<String> {
let p = part.trim().to_lowercase();
let agent = agent_of(part, app, prev.as_deref()).or_else(|| prev.clone());
let needs = |cmd: &str| -> Option<String> {
let a = agent.clone().or_else(|| agent_of(part, app, prev.as_deref()));
match a {
Some(a) => Some(format!("{cmd} {a}")),
None => Some(format!("{cmd} --all")), // "tout mettre à jour" etc.
}
};
let cmd = if p.contains("desinstalle") || p.contains("désinstalle")
|| p.contains("uninstall") || p.contains("supprime")
{
needs("uninstall")
} else if p.contains("mets a jour") || p.contains("mets à jour") || p.contains("maj ")
|| p.contains("mise a jour") || p.contains("update") || p.contains("upgrade")
{
if agent.is_some() {
needs("update")
} else {
Some("update --all".to_string())
}
} else if p.contains("installe") || p.contains("install") || p.contains("setup")
|| p.contains("instal")
{
needs("install")
} else if p.contains("arrete") || p.contains("arrête") || p.contains("stop")
|| p.contains("kill") || p.contains("tue") || p.contains("eteins") || p.contains("éteins")
{
needs("stop")
} else if p.contains("relance") || p.contains("redemarre") || p.contains("redémarre")
|| p.contains("restart") || p.contains("reboot")
{
needs("restart")
} else if p.contains("lance") || p.contains("start") || p.contains("demarre")
|| p.contains("démarre") || p.contains("ouvre") || p.contains("execute") || p.contains("exécute")
{
match agent {
Some(a) => Some(format!("start {a}")),
None => Some("start".to_string()),
}
} else if p.contains("historique") || p.contains("history") {
Some("history".to_string())
} else if p.contains("statistique") || p.contains("statistique") || p.contains("stats") {
Some("stats".to_string())
} else if p.contains("qui tourne") || p.contains("processus") || p.contains("ps ") {
Some("ps".to_string())
} else if p.contains("sante") || p.contains("santé") || p.contains("doctor")
|| p.contains("diagnostic") || p.contains("checkup") || p.contains("verifie tout")
{
Some("doctor".to_string())
} else if p.contains("provider") || p.contains("fournisseur") {
Some("providers list".to_string())
} else if p.contains("secret") {
Some("secret list".to_string())
} else if p.contains("config") {
Some("config show".to_string())
} else if p.contains("tout") && p.contains("liste") {
Some("list --all".to_string())
} else if p.contains("liste") || p.contains("list") || p.contains("lister")
|| p.contains("catalogue") || p.contains("agents")
{
Some("list".to_string())
} else if p.contains("journal") || p.contains("log") {
Some("logs".to_string())
} else if p.contains("aide") || p.contains("help") {
Some("help".to_string())
} else {
None
};
if let Some(c) = &cmd {
// Keep the agent context for the following pronouns.
if let Some(a) = agent_of(part, app, prev.as_deref()) {
*prev = Some(a);
}
}
cmd
}
/// Rule-based translation of a full query into am commands.
fn rules_translate(query: &str, app: &App) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut prev: Option<String> = None;
// Sentence separators (FR + EN): each chunk becomes one command.
for chunk in query.split(|c| matches!(c, ',' | ';' | '.')) {
for part in chunk.splitn(2, |c| c == ':' || c == ';') {
let part = part.trim();
if part.is_empty() {
continue;
}
// Split on conjunctions, keeping the order.
let mut pieces: Vec<String> = Vec::new();
let mut current = String::new();
for word in part.split_whitespace() {
if matches!(
word.to_lowercase().as_str(),
"et" | "puis" | "and" | "then" | "ensuite" | "alors" | "&" | "+"
) {
if !current.trim().is_empty() {
pieces.push(current.trim().to_string());
}
current.clear();
} else {
current.push_str(word);
current.push(' ');
}
}
if !current.trim().is_empty() {
pieces.push(current.trim().to_string());
}
for piece in pieces {
if let Some(cmd) = translate_part(&piece, app, &mut prev) {
if !out.contains(&cmd) {
out.push(cmd);
}
}
}
}
}
out
}
// ---------------------------------------------------------------------------
// Optional LLM refinement (issue #78) — via the provider registry (#88)
// ---------------------------------------------------------------------------
/// ask is enabled by default (local rules work out of the box, issue #78);
/// an explicit `ask.enabled: false` turns the whole command off.
fn ask_enabled(app: &App) -> bool {
app.config
.settings
.ask
.as_ref()
.map(|a| a.enabled)
.unwrap_or(true)
}
/// The optional LLM refinement settings: (provider, model), both None when
/// no provider is configured (rules-only mode).
fn ask_settings(app: &App) -> Option<(Option<&str>, Option<&str>)> {
if !ask_enabled(app) {
return None;
}
let a = app.config.settings.ask.as_ref();
Some((
a.and_then(|x| x.provider.as_deref()).filter(|p| !p.is_empty()),
a.and_then(|x| x.model.as_deref()).filter(|m| !m.is_empty()),
))
}
fn is_known_command(first_word: &str) -> bool {
matches!(
first_word,
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "list" | "status" | "ps" | "logs" | "history" | "stats" | "top"
| "report" | "doctor" | "config" | "providers" | "secret" | "suggest"
| "search" | "info" | "open" | "watch" | "sync" | "web" | "monitor"
| "schedule" | "ask" | "lab" | "playbook"
)
}
/// Ask the provider registry to translate the query. Returns None when no
/// provider is configured (rules-only mode). Only the user's query is sent —
/// never journal data.
fn llm_translate(app: &App, query: &str) -> Result<Option<Vec<String>>> {
let Some((provider, model)) = ask_settings(app) else {
return Ok(None);
};
let provider_name = provider.or_else(|| crate::providers::default_name(&app.config));
let Some(provider_name) = provider_name else {
return Ok(None);
};
let Some(def) = crate::providers::get(&app.config, provider_name) else {
return Ok(None);
};
let model = model.or(def.default_model.as_deref());
let Some(model) = model else {
return Ok(None);
};
let token = crate::secrets::store()
.get(&crate::secrets::key_for_provider_token(provider_name))
.ok()
.flatten();
let endpoint = format!("{}/chat/completions", def.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"temperature": 0,
"max_tokens": 256,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": query},
],
});
let mut req = ureq::post(&endpoint)
.set("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(20));
if let Some(t) = &token {
req = req.set("Authorization", &format!("Bearer {t}"));
}
let resp = req
.send_string(&body.to_string())
.map_err(|e| anyhow!("{e}"))?;
let json: serde_json::Value = serde_json::from_reader(resp.into_reader())?;
let content = json
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
.ok_or_else(|| anyhow!("réponse du fournisseur illisible"))?;
let commands: Vec<String> = content
.lines()
.map(|l| l.trim().trim_start_matches("am ").trim().to_string())
.filter(|l| !l.is_empty())
.filter(|l| {
let first = l.split_whitespace().next().unwrap_or("");
is_known_command(first)
})
.collect();
if commands.is_empty() {
anyhow::bail!("le fournisseur n'a produit aucune commande valide");
}
Ok(Some(commands))
}
// ---------------------------------------------------------------------------
// Cache (offline reuse, issue #78)
// ---------------------------------------------------------------------------
#[derive(Debug, Default, Serialize, Deserialize)]
struct AskCache {
entries: BTreeMap<String, Vec<String>>,
}
fn cache_path(app: &App) -> PathBuf {
app.paths
.state_file
.parent()
.unwrap_or(Path::new("."))
.join("ask_cache.json")
}
fn cache_load(app: &App) -> AskCache {
std::fs::read_to_string(cache_path(app))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn cache_save(app: &App, cache: &AskCache) {
if let Ok(json) = serde_json::to_string_pretty(cache) {
let _ = std::fs::write(cache_path(app), json);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Translate a natural-language query into am commands (rules first, then
/// the optional LLM, both cached for offline reuse).
pub fn translate(app: &App, query: &str) -> Result<Vec<String>> {
let key = query.trim().to_lowercase();
if key.is_empty() {
anyhow::bail!("usage: am ask <demande en langage naturel>");
}
// 1. Cache hit — works offline, even after a previous LLM translation.
let mut cache = cache_load(app);
if let Some(hit) = cache.entries.get(&key) {
return Ok(hit.clone());
}
// 2. Local rules (zero dependency).
let rules = rules_translate(query, app);
if !rules.is_empty() {
cache.entries.insert(key.clone(), rules.clone());
cache_save(app, &cache);
return Ok(rules);
}
// 3. Optional LLM refinement through the provider registry. Failures
// (no token, provider unreachable, malformed answer) degrade to the
// rules-only result instead of blocking the command.
match llm_translate(app, query) {
Ok(Some(llm)) => {
cache.entries.insert(key.clone(), llm.clone());
cache_save(app, &cache);
return Ok(llm);
}
Ok(None) => {}
Err(e) => {
app.log
.verbose(&format!("ask LLM refinement skipped: {e:#}"));
}
}
Ok(Vec::new())
}
/// am ask entry point: translate, show, confirm, execute.
pub fn run(app: &App, query: &str, yes: bool) -> Result<i32> {
if !ask_enabled(app) {
app.log.error(crate::i18n::tr(
"am ask est désactivé — settings.ask.enabled: true pour l'activer",
));
return Ok(1);
}
let commands = translate(app, query)?;
if commands.is_empty() {
app.log.info(crate::i18n::tr(
"je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»",
));
return Ok(0);
}
for c in &commands {
println!(" am {c}");
}
if !yes && !app.confirm(&crate::i18n::tr("exécuter ces commandes ?"))? {
app.log.info(crate::i18n::tr("annulé"));
return Ok(0);
}
for c in &commands {
let tokens = shell_words::split(c)?;
let mut argv = vec!["am".to_string()];
argv.extend(tokens);
let cli = crate::cli::Cli::parse_from(argv);
let Some(cmd) = cli.command else {
continue;
};
let code = execute_command(app, &cmd)?;
if code != 0 {
return Ok(code);
}
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use clap::Parser;
fn test_app(ask_yaml: &str) -> App {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{ask_yaml}agents: []\n"
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.state_file = dir.join("state.json");
app.paths = p;
app
}
#[test]
fn rules_install_and_launch_claude() {
let app = test_app("");
let cmds = translate(&app, "installe claude et lance-le").unwrap();
assert_eq!(cmds, vec!["install claude-code", "start claude-code"]);
}
#[test]
fn rules_cover_common_requests() {
let app = test_app("");
assert_eq!(translate(&app, "liste les agents").unwrap(), vec!["list"]);
assert_eq!(translate(&app, "arrête codex").unwrap(), vec!["stop codex"]);
assert_eq!(
translate(&app, "mets à jour tout").unwrap(),
vec!["update --all"]
);
assert_eq!(translate(&app, "qui tourne ?").unwrap(), vec!["ps"]);
assert_eq!(
translate(&app, "installe deepseek-harness").unwrap(),
vec!["install deepseek-harness"]
);
}
#[test]
fn rules_unknown_query_is_empty() {
let app = test_app("");
assert!(translate(&app, "quelle est la météo demain ?").unwrap().is_empty());
}
#[test]
fn cache_reuses_translations_offline() {
let app = test_app("");
let cmds = translate(&app, "installe claude et lance-le").unwrap();
assert_eq!(cmds.len(), 2);
// Second pass: served from the cache file (rules are deterministic,
// but the cache also carries previous LLM translations offline).
let cache = cache_load(&app);
assert!(cache.entries.contains_key("installe claude et lance-le"));
}
#[test]
fn llm_refinement_uses_the_registry_and_never_sends_journal_data() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let app = test_app(&format!(
" default_provider: openai\n ask:\n enabled: true\n provider: openai\n providers:\n openai:\n base_url: http://127.0.0.1:{port}\n default_model: gpt-5.2\n"
));
// Store a fake token in the keyring namespace of the provider.
let store = crate::secrets::store();
let key = crate::secrets::key_for_provider_token("openai");
store.set(&key, "sk-test-token").unwrap();
let handle = std::thread::spawn(move || {
let mut req = server
.recv_timeout(std::time::Duration::from_secs(10))
.expect("server recv failed")
.expect("the ask call must reach the provider");
assert_eq!(req.url(), "/chat/completions");
let mut body = String::new();
req.as_reader().read_to_string(&mut body).unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["model"], "gpt-5.2");
assert_eq!(json["temperature"], 0);
let user_msg = json["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "user")
.unwrap()["content"]
.as_str()
.unwrap()
.to_string();
let auth = req.headers().iter().find(|h| h.field.equiv("Authorization")).map(|h| h.value.as_str().to_string());
let _ = req.respond(tiny_http::Response::from_string(
r#"{"choices":[{"message":{"content":"install claude-code\nstart claude-code"}}]}"#,
));
(user_msg, auth)
});
// The rules do NOT cover this query — the LLM refinement is reached.
let cmds = translate(&app, "donne-moi un rapport de la semaine").unwrap();
assert_eq!(cmds, vec!["install claude-code", "start claude-code"]);
let (user_msg, auth) = handle.join().unwrap();
// The provider receives the query and the keyring token — nothing else.
assert_eq!(user_msg, "donne-moi un rapport de la semaine");
assert_eq!(auth.as_deref(), Some("Bearer sk-test-token"));
store.remove(&key).unwrap();
}
#[test]
fn llm_failure_degrades_to_rules_only() {
// No provider configured explicitly: the embedded default (anthropic)
// would 401 without a token — translate() must degrade silently to
// the rules-only result instead of blocking.
let app = test_app("");
let cmds = translate(&app, "une requête hors règles").unwrap();
assert!(cmds.is_empty());
}
#[test]
fn disabled_ask_is_refused() {
let app = test_app(" ask:\n enabled: false\n");
// run() reports the refusal with exit code 1 (not an error).
assert_eq!(run(&app, "installe claude", true).unwrap(), 1);
}
}
+438
View File
@@ -0,0 +1,438 @@
//! Automation helpers (axe 6): system service units, scheduled tasks,
//! container commands and health checks. Pure functions here are unit
//! tested; the thin command layers live in commands/service_cmd.rs,
//! commands/schedule_cmd.rs, run_cmd.rs and doctor_cmd.rs.
use crate::config::{ContainerSpec, HealthCheck};
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Am binary path used inside generated units/tasks (quoted).
pub fn am_bin_quoted() -> String {
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "am".to_string());
shell_escape(&exe)
}
fn shell_escape(s: &str) -> String {
if cfg!(windows) {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
// ---------------------------------------------------------------------------
// #55: system service units
// ---------------------------------------------------------------------------
/// systemd user unit for 'am service install <agent> --autostart'.
pub fn systemd_unit(agent: &str, am_bin: &str) -> String {
format!(
"# Generated by 'am service install {agent}' — managed by agent-manager\n\
[Unit]\n\
Description=agent-manager: {agent}\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={am_bin} start {agent} --background --notify\n\
Restart=on-failure\n\
RestartSec=10\n\
\n\
[Install]\n\
WantedBy=default.target\n"
)
}
/// launchd plist for macOS.
pub fn launchd_plist(agent: &str, am_bin: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>Label</key>\n\
\t<string>com.agent-manager.{agent}</string>\n\
\t<key>ProgramArguments</key>\n\
\t<array>\n\
\t\t<string>{am_bin}</string>\n\
\t\t<string>start</string>\n\
\t\t<string>{agent}</string>\n\
\t\t<string>--background</string>\n\
\t\t<string>--notify</string>\n\
\t</array>\n\
\t<key>RunAtLoad</key>\n\
\t<true/>\n\
\t<key>KeepAlive</key>\n\
\t<true/>\n\
</dict>\n\
</plist>\n"
)
}
/// Windows scheduled task XML (on logon, with retry).
pub fn windows_task_xml(agent: &str, am_bin: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
\t<Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>\n\
\t<Principals><Principal id=\"Author\"><LogonType>InteractiveToken</LogonType>\n\
\t<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n\
\t<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
\t<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
\t<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
\t<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>\n\
\t</Settings>\n\
\t<Actions><Exec>\n\
\t<Command>{am_bin}</Command>\n\
\t<Arguments>start {agent} --background --notify</Arguments>\n\
\t</Exec></Actions>\n\
</Task>\n"
)
}
/// Path of the systemd user unit for an agent.
pub fn systemd_unit_path(agent: &str) -> PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
crate::config::home_dir()
.map(|h| h.join(".config"))
.unwrap_or_else(|| PathBuf::from(".config"))
});
base.join("systemd").join("user").join(format!("am-{agent}.service"))
}
/// Path of the launchd plist for an agent.
pub fn launchd_path(agent: &str) -> PathBuf {
crate::config::home_dir()
.map(|h| h.join("Library/LaunchAgents"))
.unwrap_or_else(|| PathBuf::from("."))
.join(format!("com.agent-manager.{agent}.plist"))
}
/// Windows task name for an agent.
pub fn windows_task_name(agent: &str) -> String {
format!("am-{agent}")
}
// ---------------------------------------------------------------------------
// #56: scheduled tasks
// ---------------------------------------------------------------------------
/// HH:MM at which a "daily" schedule runs (default 08:00). Parsed leniently.
pub fn parse_hhmm(at: &str) -> (u8, u8) {
let parts: Vec<&str> = at.split(':').collect();
let h: u8 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(8);
let m: u8 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
(h.min(23), m.min(59))
}
/// Crontab line for a daily schedule (Linux/macOS).
pub fn crontab_line(at: &str, am_bin: &str, schedule_id: &str) -> String {
let (h, m) = parse_hhmm(at);
format!("{m} {h} * * * {am_bin} schedule-run {schedule_id}")
}
/// schtasks command registering a daily task (Windows).
pub fn schtasks_daily(at: &str, am_bin: &str, schedule_id: &str, task_name: &str) -> Command {
let (h, m) = parse_hhmm(at);
let mut c = Command::new("schtasks");
c.args([
"/create",
"/tn",
task_name,
"/sc",
"daily",
"/st",
&format!("{h:02}:{m:02}"),
"/tr",
&format!("\"{am_bin}\" schedule-run {schedule_id}"),
"/f",
]);
c
}
/// The command that actually runs a schedule: am schedule-run <id>.
pub fn schedule_run_command(app: &crate::app::App, entry: &crate::state::ScheduleEntry) -> Result<i32> {
// Re-dispatch through the CLI so hooks/events/state stay consistent.
let args: Vec<String> = entry
.command
.iter()
.map(|s| s.to_string())
.collect();
let mut full = vec!["am".to_string()];
if let Some(cfg) = &app.config_origin {
full.push("--config".to_string());
full.push(cfg.to_string_lossy().to_string());
}
full.extend(args);
let status = Command::new(std::env::current_exe()?)
.args(&full[1..])
.status()?;
Ok(status.code().unwrap_or(1))
}
// ---------------------------------------------------------------------------
// #57: health checks
// ---------------------------------------------------------------------------
/// Wait for an agent health check (issue #57). Returns Ok when the check
/// passes within the timeout, Err otherwise. Never probes when the agent
/// has no healthcheck configured.
pub fn wait_health(agent: &str, hc: Option<&HealthCheck>, log: &crate::output::Logger) -> Result<()> {
let Some(hc) = hc else {
return Ok(());
};
let timeout = hc.timeout_secs.unwrap_or(30).max(1);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
let mut attempt = 0;
loop {
attempt += 1;
let ok = healthcheck_once(hc);
if ok {
return Ok(());
}
if std::time::Instant::now() >= deadline {
bail!(
"health check for '{agent}' failed after {timeout}s \
(command: {}, port: {})",
hc.command.as_deref().unwrap_or("-"),
hc.port.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string())
);
}
if attempt == 1 || attempt % 5 == 0 {
log.verbose(&format!(
"waiting for '{agent}' health (attempt {attempt})..."
));
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
/// One health check probe: command exit 0, or TCP connect on the port.
pub fn healthcheck_once(hc: &HealthCheck) -> bool {
if let Some(cmd) = &hc.command {
let ok = Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return false;
}
}
if let Some(port) = hc.port {
if TcpStream::connect(("127.0.0.1", port)).is_err() {
return false;
}
}
true
}
// ---------------------------------------------------------------------------
// #58: container profiles
// ---------------------------------------------------------------------------
/// Detect a container runtime: podman preferred, docker fallback.
pub fn detect_runtime() -> Option<String> {
for bin in ["podman", "docker"] {
if Command::new(bin)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return Some(bin.to_string());
}
}
None
}
/// Build the container run command for an agent (issue #58): mounts the
/// state dir and log dir, applies the profile volumes/network/env, runs
/// the agent's run command in the image.
pub fn container_command(
runtime: &str,
agent: &str,
spec: &ContainerSpec,
state_dir: &Path,
log_dir: &Path,
) -> Result<Vec<String>> {
if spec.image.trim().is_empty() {
bail!("agent '{agent}': container profile requires an 'image'");
}
let mut cmd = vec![
runtime.to_string(),
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
format!("am-{agent}"),
];
// State + logs mounts so sessions/history keep working.
cmd.push("-v".to_string());
cmd.push(format!("{}:/data", state_dir.to_string_lossy()));
cmd.push("-v".to_string());
cmd.push(format!("{}:/logs", log_dir.to_string_lossy()));
for v in &spec.volumes {
cmd.push("-v".to_string());
cmd.push(v.clone());
}
match &spec.network {
Some(n) => {
cmd.push("--network".to_string());
cmd.push(n.clone());
}
None => {
cmd.push("--network".to_string());
cmd.push(
if cfg!(target_os = "linux") {
"host".to_string()
} else {
"bridge".to_string()
},
);
}
}
for (k, v) in &spec.env {
cmd.push("-e".to_string());
cmd.push(format!("{k}={v}"));
}
cmd.push(spec.image.clone());
Ok(cmd)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systemd_unit_is_valid_and_referenced() {
let unit = systemd_unit("claude-code", "/usr/local/bin/am");
assert!(unit.contains("[Unit]"));
assert!(unit.contains("ExecStart=/usr/local/bin/am start claude-code --background --notify"));
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("[Install]"));
}
#[test]
fn launchd_plist_is_valid_xml_shape() {
let plist = launchd_plist("aider", "/opt/homebrew/bin/am");
assert!(plist.contains("com.agent-manager.aider"));
assert!(plist.contains("<string>/opt/homebrew/bin/am</string>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
}
#[test]
fn windows_task_xml_has_action() {
let xml = windows_task_xml("codex", "C:\\bin\\am.exe");
assert!(xml.contains("LogonTrigger"));
assert!(xml.contains("am.exe"));
assert!(xml.contains("start codex --background --notify"));
assert!(xml.contains("RestartOnFailure"));
}
#[test]
fn crontab_line_formats_time_and_id() {
let line = crontab_line("22:05", "/usr/bin/am", "sched-1");
assert_eq!(line, "5 22 * * * /usr/bin/am schedule-run sched-1");
let line = crontab_line("", "/usr/bin/am", "sched-2");
assert_eq!(line, "0 8 * * * /usr/bin/am schedule-run sched-2");
}
#[test]
fn parse_hhmm_is_lenient() {
assert_eq!(parse_hhmm("09:30"), (9, 30));
assert_eq!(parse_hhmm("garbage"), (8, 0));
assert_eq!(parse_hhmm("25:99"), (23, 59));
}
#[test]
fn healthcheck_command_exit_0_is_healthy() {
let hc = HealthCheck {
command: Some("true".to_string()),
port: None,
timeout_secs: Some(5),
};
assert!(healthcheck_once(&hc));
let bad = HealthCheck {
command: Some("false".to_string()),
port: None,
timeout_secs: Some(5),
};
assert!(!healthcheck_once(&bad));
}
#[test]
fn healthcheck_without_config_skips() {
let app = test_app();
assert!(wait_health("x", None, &app.log).is_ok());
}
fn test_app() -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
app.paths.state_file = dir.path().join("state.json");
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
app
}
#[test]
fn container_command_mounts_and_env() {
let spec = ContainerSpec {
image: "node:20".to_string(),
volumes: vec!["/host/work:/work:ro".to_string()],
network: None,
env: BTreeMap::from([("NODE_ENV".to_string(), "production".to_string())]),
};
let cmd = container_command(
"docker",
"my-agent",
&spec,
Path::new("/state"),
Path::new("/logs"),
)
.unwrap();
assert_eq!(cmd[0], "docker");
assert!(cmd.contains(&"run".to_string()));
assert!(cmd.contains(&"-v".to_string()));
assert!(cmd.contains(&"/state:/data".to_string()));
assert!(cmd.contains(&"/logs:/logs".to_string()));
assert!(cmd.contains(&"--network".to_string()));
assert!(cmd.contains(&"-e".to_string()));
assert!(cmd.contains(&"NODE_ENV=production".to_string()));
assert_eq!(cmd.last().unwrap(), "node:20");
// Empty image fails clearly.
let bad = ContainerSpec { image: String::new(), ..Default::default() };
assert!(container_command("docker", "a", &bad, Path::new("/s"), Path::new("/l")).is_err());
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Pre-update backups (issue #64, axe 8): a snapshot of the install
//! directory, the state database and the user config is taken before every
//! real update, kept under <state>/backups/<timestamp>/, and restored by
//! 'am update --rollback <point>'. Retention is bounded by
//! settings.backups_keep (default 5).
use crate::app::App;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Default number of backups to keep (settings.backups_keep default).
pub const DEFAULT_KEEP: u32 = 5;
/// Manifest of one backup point (JSON file inside the backup directory).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupInfo {
/// Backup id = the directory name (RFC 3339 timestamp).
pub id: String,
/// RFC 3339 creation timestamp.
pub created_at: String,
/// Reason: "update:<agent>" or "update:--all".
pub reason: String,
/// Agents whose install directories were snapshotted (empty = all).
pub agents: Vec<String>,
/// True when the state database was snapshotted.
pub state: bool,
/// True when the user config file was snapshotted.
pub config: bool,
}
impl BackupInfo {
/// Load the manifest of one backup directory.
fn load(dir: &Path) -> Option<BackupInfo> {
let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
serde_json::from_str(&text).ok()
}
}
/// Directory holding every backup point. Lives next to the state database
/// so tests and --config overrides stay fully sandboxed.
pub fn backups_dir(app: &App) -> PathBuf {
app.paths
.state_file
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("backups")
}
/// Take a snapshot before an update. `agents` lists the agents being
/// updated; an empty list means "the whole install directory".
pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result<BackupInfo> {
let now = crate::installers::now_rfc3339();
// Directory names must be sortable and filesystem-safe: the RFC 3339
// timestamp with colons replaced by dashes.
let id = now.replace(':', "-").replace('Z', "").replace('+', "_");
let root = backups_dir(app).join(&id);
std::fs::create_dir_all(&root)
.with_context(|| format!("cannot create backup directory {}", root.display()))?;
let mut info = BackupInfo {
id: id.clone(),
created_at: now,
reason: reason.to_string(),
agents: agents.to_vec(),
state: false,
config: false,
};
// 1. State database.
if let Ok(sf) = app.state.load() {
let text = serde_json::to_string_pretty(&sf)?;
std::fs::write(root.join("state.json"), text)
.with_context(|| "cannot snapshot state.json".to_string())?;
info.state = true;
}
// 2. User config file (the active origin when it is a user file).
if let Some(origin) = &app.config_origin {
if origin.is_file() {
std::fs::copy(origin, root.join("config.yaml")).with_context(|| {
format!("cannot snapshot user config {}", origin.display())
})?;
info.config = true;
}
}
// 3. Install directories of the updated agents (or everything).
let install_root = &app.paths.install_dir;
let dest_root = root.join("install");
std::fs::create_dir_all(&dest_root)?;
if agents.is_empty() {
copy_tree(install_root, &dest_root)
.with_context(|| format!("cannot snapshot {}", install_root.display()))?;
} else {
for name in agents {
let entry = app.state.get(name).ok().flatten();
if let Some(e) = entry {
let src = PathBuf::from(&e.install_dir);
if src.is_dir() {
let dest = dest_root.join(&e.name);
copy_tree(&src, &dest).with_context(|| {
format!("cannot snapshot {} for {name}", src.display())
})?;
}
}
// Also snapshot the shim binaries (bin/<name>).
let shim = app.paths.bin_dir.join(name);
if shim.exists() {
copy_tree(&shim, &dest_root.join("bin").join(name))?;
}
}
}
// Write the manifest last: a backup without a manifest is invalid.
let manifest = serde_json::to_string_pretty(&info)?;
std::fs::write(root.join("manifest.json"), manifest)
.with_context(|| "cannot write backup manifest".to_string())?;
prune(app)?;
Ok(info)
}
/// List every backup point, oldest first.
pub fn list_backups(app: &App) -> Vec<BackupInfo> {
let mut out: Vec<BackupInfo> = Vec::new();
if let Ok(entries) = std::fs::read_dir(backups_dir(app)) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(info) = BackupInfo::load(&path) {
out.push(info);
}
}
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
/// Resolve a rollback point: "latest"/empty = most recent backup,
/// otherwise the backup id (prefix matching allowed).
pub fn resolve_point(app: &App, point: Option<&str>) -> Result<BackupInfo> {
let all = list_backups(app);
if all.is_empty() {
bail!("no backup found — nothing to roll back to");
}
match point {
None | Some("latest") => all
.last()
.cloned()
.ok_or_else(|| anyhow!("no backup found")),
Some(p) => {
let exact = all.iter().find(|b| b.id == p);
if let Some(b) = exact {
return Ok(b.clone());
}
let prefix: Vec<BackupInfo> = all
.iter()
.filter(|b| b.id.starts_with(p))
.cloned()
.collect();
match prefix.len() {
0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"),
1 => Ok(prefix[0].clone()),
_ => bail!(
"backup prefix '{p}' is ambiguous ({} matches: {})",
prefix.len(),
prefix
.iter()
.map(|b| b.id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
}
}
/// Restore a backup point over the live install: state, user config and
/// install directories. The agent processes of the restored agents are
/// stopped first (they would hold the old binaries open).
pub fn restore_backup(app: &App, point: Option<&str>) -> Result<BackupInfo> {
let info = resolve_point(app, point)?;
let root = backups_dir(app).join(&info.id);
if !root.join("manifest.json").is_file() {
bail!("backup {} is corrupted (no manifest)", info.id);
}
// Stop the agents being restored (only managed ones we know).
let names: Vec<String> = if info.agents.is_empty() {
app.state
.all()?
.keys()
.cloned()
.collect()
} else {
info.agents.clone()
};
for name in &names {
if let Ok(Some(entry)) = app.state.get(name) {
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log
.info(&format!("stopping {name} (pid {pid}) before rollback"));
let _ = crate::commands::run_cmd::stop(app, name, false, None);
}
}
}
}
// 1. Restore the state database.
if info.state {
let src = root.join("state.json");
if src.is_file() {
std::fs::copy(&src, app.state.path())
.with_context(|| "cannot restore state.json".to_string())?;
}
}
// 2. Restore the user config.
if info.config {
if let Some(origin) = &app.config_origin {
if let Some(parent) = origin.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(root.join("config.yaml"), origin)
.with_context(|| format!("cannot restore {}", origin.display()))?;
}
}
// 3. Restore the install directories.
let src_root = root.join("install");
if src_root.is_dir() {
if info.agents.is_empty() {
if app.paths.install_dir.exists() {
std::fs::remove_dir_all(&app.paths.install_dir)
.with_context(|| "cannot clear the install directory".to_string())?;
}
copy_tree(&src_root, &app.paths.install_dir)?;
} else {
for name in &info.agents {
let src = src_root.join(name);
if !src.exists() {
continue;
}
// Remove the agent's current install dir (known from the
// restored state) and copy the snapshot back.
if let Ok(Some(entry)) = app.state.get(name) {
let cur = PathBuf::from(&entry.install_dir);
if cur.is_dir() {
let _ = std::fs::remove_dir_all(&cur);
}
}
let dest = app.paths.install_dir.join(name);
copy_tree(&src, &dest)?;
}
// Restore the shims.
let bin_src = src_root.join("bin");
if bin_src.is_dir() {
copy_tree(&bin_src, &app.paths.bin_dir)?;
}
}
}
Ok(info)
}
/// Enforce settings.backups_keep: delete the oldest backups beyond the cap.
pub fn prune(app: &App) -> Result<()> {
let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize;
let all = list_backups(app);
if all.len() <= keep {
return Ok(());
}
for old in all.iter().take(all.len() - keep) {
let dir = backups_dir(app).join(&old.id);
if dir.is_dir() {
let _ = std::fs::remove_dir_all(&dir);
}
}
Ok(())
}
/// Recursively copy a directory tree (files and directories only; symlinks
/// are copied as-is when possible).
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
return Ok(());
}
let meta = std::fs::metadata(src)?;
if meta.is_file() {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dest)?;
return Ok(());
}
std::fs::create_dir_all(dest)?;
let mut entries: Vec<_> = std::fs::read_dir(src)?
.flatten()
.map(|e| e.path())
.collect();
entries.sort();
for child in entries {
let name = child
.file_name()
.ok_or_else(|| anyhow!("invalid path {}", child.display()))?;
copy_tree(&child, &dest.join(name))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::state::StateFile;
use clap::Parser;
use std::io::Write;
fn test_app(tag: &str) -> App {
let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"--yes",
"--no-color",
]);
let mut app = App::from_cli(cli).expect("app builds");
// Point the state/install dirs into the temp dir so nothing touches
// the real user data.
let state_file = dir.join("state.json");
let install = dir.join("agents");
std::fs::create_dir_all(&install).unwrap();
app.paths.state_file = state_file.clone();
app.paths.install_dir = install.clone();
app.paths.bin_dir = install.join("bin");
app.state = crate::state::StateStore::new(state_file);
app.config_origin = Some(cfg);
// Fresh backups dir for this test run.
let _ = std::fs::remove_dir_all(backups_dir(&app));
app
}
fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf {
let dir = app.paths.install_dir.join(name);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("agent.txt");
let mut h = std::fs::File::create(&f).unwrap();
h.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn backup_create_list_restore_roundtrip() {
let app = test_app("roundtrip");
write_agent_file(&app, "alpha", "v1");
write_agent_file(&app, "beta", "b1");
// Simulate a managed entry for alpha.
let mut sf = StateFile::default();
sf.installed.insert(
"alpha".to_string(),
crate::state::InstalledEntry {
name: "alpha".to_string(),
version: Some("1.0.0".to_string()),
method: "binary".to_string(),
install_dir: app.paths.install_dir.join("alpha").display().to_string(),
bins: vec![],
run: "alpha".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
},
);
app.state.save(&sf).unwrap();
let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap();
assert!(info.state && info.config);
// Corrupt the live install + state.
write_agent_file(&app, "alpha", "BROKEN");
let mut bad = StateFile::default();
bad.sessions_count = 999;
app.state.save(&bad).unwrap();
restore_backup(&app, Some(&info.id)).unwrap();
let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap();
assert_eq!(text, "v1", "install dir restored");
let sf = app.state.load().unwrap();
assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0"));
assert_eq!(sf.sessions_count, 0, "state restored");
}
#[test]
fn resolve_point_prefix_and_latest() {
let app = test_app("resolve");
create_backup(&app, "update:a", &[]).unwrap();
let info = resolve_point(&app, None).unwrap();
assert_eq!(info.agents, Vec::<String>::new());
let prefix = &info.id[..info.id.len().min(10)];
let by_prefix = resolve_point(&app, Some(prefix)).unwrap();
assert_eq!(by_prefix.id, info.id);
}
#[test]
fn prune_keeps_only_backups_keep() {
let mut app = test_app("prune");
app.config.settings.backups_keep = Some(1);
create_backup(&app, "update:one", &[]).unwrap();
create_backup(&app, "update:two", &[]).unwrap();
let all = list_backups(&app);
assert_eq!(all.len(), 1, "only the newest backup survives");
}
}
+2 -1
View File
@@ -133,7 +133,8 @@ impl Catalog {
/// Score a keyword against one haystack field: 100 for a full hit,
/// then subsequence and typo bonuses; 0 = no relation at all.
fn score_keyword(keyword: &str, hay: &str) -> u32 {
/// Public so `am suggest` can reuse the exact same ranking (issue #61).
pub fn score_keyword(keyword: &str, hay: &str) -> u32 {
if keyword.is_empty() {
return 0;
}
+303
View File
@@ -0,0 +1,303 @@
//! Remote catalogs (issues #60 #67, axes 7 & 9): fetching a catalog from
//! an http(s) URL, validating its format, and caching it on disk the
//! probe.rs way (keyed by URL + timestamp, TTL-bounded). Used both by
//! 'am catalog update/add' and by config `include:` entries pointing at
//! URLs.
//!
//! Rules:
//! * only http, https and file:// URLs are accepted (download::validate_url);
//! * a cached copy is served while it is fresh; on network failure the
//! stale cache is used with a warning (offline support);
//! * a malformed catalog is an error, never silently skipped.
use crate::config::{self, Config};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Default time-to-live of a cached remote catalog, in seconds (1 h).
pub const DEFAULT_TTL_SECS: u64 = 3600;
/// Base URL of the official catalog (the agent-manager repo on Gitea).
pub const DEFAULT_CATALOG_URL: &str =
"https://git.dracodev.net/Projets/agent-manager/raw/branch/main/config.yaml";
/// One cached catalog: the raw text, the URL it came from, and the fetch
/// time (seconds since the epoch).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedCatalog {
pub url: String,
pub fetched_at: u64,
pub text: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CatalogCache {
pub entries: BTreeMap<String, CachedCatalog>,
}
impl CatalogCache {
fn load(state_dir: &Path) -> Self {
std::fs::read_to_string(state_dir.join("catalog-cache.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn save(&self, state_dir: &Path) {
if let Ok(text) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(state_dir.join("catalog-cache.json"), text);
}
}
}
/// Fetch the text of a catalog URL, using and refreshing the on-disk cache.
/// `force` bypasses the TTL (used by 'am catalog update').
pub fn fetch(app: &crate::app::App, url: &str, force: bool) -> Result<String> {
let ttl = app
.config
.settings
.catalog_cache_ttl_secs
.unwrap_or(DEFAULT_TTL_SECS);
fetch_with_cache(&app.events_dir(), url, force, ttl, Some(&app.log))
}
/// Fetch a catalog URL during config loading (no App available yet): the
/// cache lives in the state directory and the TTL is the default one.
pub fn fetch_for_config(state_dir: &Path, url: &str) -> Result<String> {
fetch_with_cache(state_dir, url, false, DEFAULT_TTL_SECS, None)
}
/// Shared fetch + cache logic.
fn fetch_with_cache(
state_dir: &Path,
url: &str,
force: bool,
ttl: u64,
log: Option<&crate::output::Logger>,
) -> Result<String> {
crate::download::validate_url(url)?;
let mut cache = CatalogCache::load(state_dir);
let now = now_secs();
// Fresh cache hit: no network.
if !force {
if let Some(entry) = cache.entries.get(url) {
if now.saturating_sub(entry.fetched_at) < ttl {
return Ok(entry.text.clone());
}
}
}
// Try the network; fall back to the stale cache on failure.
let fresh = fetch_url_text(url);
match fresh {
Ok(text) => {
cache.entries.insert(
url.to_string(),
CachedCatalog {
url: url.to_string(),
fetched_at: now,
text: text.clone(),
},
);
cache.save(state_dir);
Ok(text)
}
Err(net_err) => {
if let Some(entry) = cache.entries.get(url) {
if let Some(log) = log {
log.warn(&format!(
"cannot refresh catalog {url} ({net_err:#}) — using the cached copy from {}",
format_ts(entry.fetched_at)
));
}
Ok(entry.text.clone())
} else {
Err(net_err)
}
}
}
}
/// Fetch the raw text of an http(s) or file:// URL (no cache).
fn fetch_url_text(url: &str) -> Result<String> {
if let Some(local) = url.strip_prefix("file://") {
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
let local = if cfg!(windows) && local.starts_with('/') {
local.strip_prefix('/').unwrap_or(local)
} else {
local
};
let p = PathBuf::from(local);
return std::fs::read_to_string(&p)
.with_context(|| format!("cannot read catalog file {}", p.display()));
}
let resp = ureq::get(url)
.set("User-Agent", crate::download::USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.call()
.map_err(|e| match e {
ureq::Error::Status(code, r) => anyhow!(
"HTTP {code} {} while fetching {url}",
r.status_text()
),
other => anyhow!("cannot fetch {url}: {other}"),
})?;
resp.into_string()
.with_context(|| format!("reading the catalog body of {url}"))
}
/// Parse and validate a catalog YAML text; returns the parsed Config.
pub fn parse_catalog(text: &str) -> Result<Config> {
let cfg: Config = serde_yaml::from_str(text)
.map_err(|e| anyhow!("the remote catalog is not a valid config: {e}"))?;
let problems = config::validate(&cfg);
if !problems.is_empty() {
bail!(
"the remote catalog is invalid:\n - {}",
problems.join("\n - ")
);
}
Ok(cfg)
}
/// Diff between the local effective catalog and a remote one: agent names
/// that are new, removed, or changed (run command differs).
pub struct CatalogDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<(String, String, String)>, // (name, local run, remote run)
}
pub fn diff_catalogs(local: &Config, remote: &Config) -> CatalogDiff {
let local_names: BTreeMap<&str, &str> = local
.agents
.iter()
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
.collect();
let remote_names: BTreeMap<&str, &str> = remote
.agents
.iter()
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
.collect();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (name, run) in &remote_names {
match local_names.get(name) {
None => added.push((*name).to_string()),
Some(local_run) if local_run != run => {
changed.push(((*name).to_string(), (*local_run).to_string(), (*run).to_string()));
}
_ => {}
}
}
for name in local_names.keys() {
if !remote_names.contains_key(name) {
removed.push((*name).to_string());
}
}
added.sort();
removed.sort();
changed.sort();
CatalogDiff {
added,
removed,
changed,
}
}
/// The official catalog URL: the configured value, or the Gitea default.
pub fn official_url(app: &crate::app::App) -> String {
app.config
.settings
.catalog_url
.clone()
.unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn format_ts(secs: u64) -> String {
let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
dt.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown date".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str = "version: \"1.0\"\nagents:\n - name: alpha\n run: alpha\n install: { type: binary, url: \"https://x/a\" }\n";
#[test]
fn parses_and_validates_a_remote_catalog() {
let cfg = parse_catalog(VALID).unwrap();
assert_eq!(cfg.agents.len(), 1);
assert_eq!(cfg.agents[0].name, "alpha");
}
#[test]
fn rejects_malformed_catalogs() {
assert!(parse_catalog("not: [valid").is_err());
// Missing run on an installable agent is invalid.
assert!(parse_catalog(
"version: \"1.0\"\nagents:\n - name: x\n install: { type: npm, package: y }\n"
)
.is_err());
}
#[test]
fn diff_detects_added_removed_changed() {
let local: Config = serde_yaml::from_str(
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: gone, run: gone, install: { type: binary, url: \"https://x/g\" } }\n - { name: tweaked, run: old, install: { type: binary, url: \"https://x/t\" } }\n",
)
.unwrap();
let remote: Config = serde_yaml::from_str(
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: fresh, run: fresh, install: { type: binary, url: \"https://x/f\" } }\n - { name: tweaked, run: new, install: { type: binary, url: \"https://x/t\" } }\n",
)
.unwrap();
let d = diff_catalogs(&local, &remote);
assert_eq!(d.added, vec!["fresh".to_string()]);
assert_eq!(d.removed, vec!["gone".to_string()]);
assert_eq!(d.changed.len(), 1);
assert_eq!(d.changed[0].0, "tweaked");
assert_eq!(d.changed[0].1, "old");
assert_eq!(d.changed[0].2, "new");
}
#[test]
fn file_url_fetch_works_offline() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cat.yaml");
std::fs::write(&p, VALID).unwrap();
let url = format!("file://{}", p.display());
let text = fetch_url_text(&url).unwrap();
assert!(text.contains("alpha"));
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut cache = CatalogCache::default();
cache.entries.insert(
"https://x".to_string(),
CachedCatalog {
url: "https://x".to_string(),
fetched_at: 42,
text: "body".to_string(),
},
);
cache.save(dir.path());
let back = CatalogCache::load(dir.path());
assert_eq!(back.entries["https://x"].text, "body");
}
}
+448 -6
View File
@@ -54,6 +54,10 @@ pub struct Cli {
#[arg(long, global = true, value_name = "THEME")]
pub theme: Option<String>,
/// Output language: fr (default) or en (issue #74)
#[arg(long, global = true, value_enum, value_name = "LANG")]
pub lang: Option<crate::i18n::LangArg>,
#[command(subcommand)]
pub command: Option<Command>,
}
@@ -157,6 +161,11 @@ pub enum Command {
#[arg(long, value_name = "DIR")]
output: Option<PathBuf>,
},
/// Set or show the active color theme (persisted for the next run)
Theme {
/// Theme name (omit to list available themes)
name: Option<String>,
},
/// Internal: list agent names for shell completions (hidden)
#[command(name = "_agents", hide = true)]
Agents {
@@ -170,6 +179,50 @@ pub enum Command {
/// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)]
Secret(SecretCmd),
/// Audit the configuration: who changed what, when (issue #63)
Audit,
/// Register an agent as a system service (issue #55)
#[command(subcommand)]
Service(ServiceCmd),
/// Schedule am commands (issue #56)
#[command(subcommand)]
Schedule(ScheduleCmd),
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
Models(ModelsArgs),
/// Community registry (issue #77): publish, search and install agent
/// catalogs hosted on Gitea.
Registry(RegistryArgs),
/// Manage remote catalogs: update the official one, add external ones
#[command(subcommand)]
Catalog(CatalogCmd),
/// Manage the LLM provider registry: base URLs, models, default provider
Providers(ProviderArgs),
/// Suggest agents matching a query, boosted by real usage (issue #61)
Suggest {
/// Free-form request ("un agent pour du Python")
#[arg(value_name = "QUERY", num_args = 1.., required = true)]
words: Vec<String>,
},
/// Ask a natural-language request and get the matching am command(s)
/// (issue #78): local rules first, optional LLM refinement, confirmation
/// before execution.
Ask {
/// The request in natural language
#[arg(value_name = "REQUEST", num_args = 1.., required = true)]
query: Vec<String>,
/// Skip the confirmation prompt
#[arg(long)]
yes: bool,
},
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
#[command(alias = "shell")]
Ai(AiArgs),
/// Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
Setup {
/// Only (re)generate the am-* copilot roles for aichat
#[arg(long)]
roles: bool,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -215,6 +268,15 @@ pub enum Command {
/// Resume a finished session: relaunch the agent with its recorded arguments
#[arg(long, value_name = "ID")]
resume: Option<String>,
/// Export one session (metadata, commands, log excerpt) to a JSON file
#[arg(long, value_name = "ID")]
export: Option<String>,
/// Destination of the export (default: ./am-session-<ID>.json)
#[arg(long, value_name = "FILE")]
output: Option<String>,
/// Purge sessions finished more than N days ago (settings default 90)
#[arg(long, value_name = "DAYS")]
retention: Option<u64>,
},
/// Show usage statistics computed from the event journal
Stats {
@@ -223,6 +285,40 @@ pub enum Command {
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
/// Show token usage and estimated spend per agent (issue #49)
#[arg(long, action = ArgAction::SetTrue)]
costs: bool,
},
/// Real-time monitor of the managed processes (issue #50)
Monitor {
/// Refresh interval in seconds (default 2)
#[arg(long, value_name = "SECONDS")]
interval: Option<u64>,
/// Print one JSON document per tick instead of the TUI
#[arg(long, action = ArgAction::SetTrue)]
json: bool,
},
/// Local read-only web dashboard with charts (issue #54)
Web(WebArgs),
/// Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
Serve(ServeArgs),
/// Push the state into the configured git repository (issue #66)
Sync {
/// Commit message (default: "am sync — state update")
#[arg(long, value_name = "MESSAGE")]
message: Option<String>,
},
/// Transfer the installation and state to another machine (issue #68)
Migrate {
/// Export the bundle (default) or import one
#[arg(long, action = ArgAction::SetTrue)]
export: bool,
/// Bundle path for export (default agent-manager-migrate.amx)
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
/// Bundle path to import
#[arg(value_name = "BUNDLE")]
bundle: Option<PathBuf>,
},
/// Show the most used agents (top 10)
Top {
@@ -288,6 +384,15 @@ pub enum Command {
/// Reinstall even if already installed
#[arg(long)]
force: bool,
/// Provider to configure (default: the agent's provider, else settings.default_provider) (issue #90)
#[arg(long, value_name = "PROVIDER")]
provider: Option<String>,
/// Model to configure (default: the provider's default model) (issue #90)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Skip the post-install provider configuration (issue #91)
#[arg(long)]
no_config: bool,
},
/// Uninstall an agent (managed or external) and clean its files
Uninstall {
@@ -304,6 +409,10 @@ pub enum Command {
/// Update every installed managed agent
#[arg(long)]
all: bool,
/// Roll back to a pre-update backup instead of updating
/// ("latest" or a backup id; "--rollback list" shows them)
#[arg(long, num_args = 0..=1, default_missing_value = "latest")]
rollback: Option<String>,
},
/// Search the catalog by keyword (name, description, category, tags)
Search {
@@ -340,7 +449,26 @@ pub enum Command {
/// Re-run the Nth most recent entry (with confirmation)
#[arg(long, value_name = "N")]
rerun: Option<usize>,
/// Range to export as a playbook: "12..25" (1-based, newest first)
#[arg(value_name = "RANGE")]
range: Option<String>,
/// Export the range as a playbook YAML file (issue #51)
#[arg(long, value_name = "FILE")]
save: Option<PathBuf>,
},
/// Replay a saved playbook step by step with confirmation (issue #51)
Playbook {
/// Playbook file (state_dir/playbooks/ when a bare name is given)
#[arg(value_name = "FILE")]
path: PathBuf,
/// Variable values, repeatable: --var name=value
#[arg(long, value_name = "NAME=VALUE")]
var: Vec<String>,
},
/// Benchmark: run the same task on several agents and compare (issue #62)
Lab(LabArgs),
/// List the event plugins and test one (issue #75)
Plugins(PluginsArgs),
/// Show detailed information about one agent
Info {
/// Agent name or alias
@@ -351,6 +479,10 @@ pub enum Command {
/// Overwrite an existing agent-manager.yaml
#[arg(long)]
force: bool,
/// Use a template (web, python, rust, cli) instead of stack detection;
/// "list" shows the available templates
#[arg(long, value_name = "TEMPLATE")]
template: Option<String>,
},
/// Display help: the overview, a command, the command list, or a search
Help {
@@ -362,6 +494,13 @@ pub enum Command {
},
/// Display version and build information
Version,
/// Cheat sheet: the most useful commands, their key options, your
/// most-used commands and a rotating tip of the day
Tip {
/// Show a single random tip instead of the whole page
#[arg(long)]
random: bool,
},
/// Manage the configuration file
#[command(subcommand)]
Config(ConfigCmd),
@@ -370,11 +509,26 @@ pub enum Command {
/// Attempt to repair problems (create directories, fix the state file)
#[arg(long)]
fix: bool,
/// Re-run the checks every N seconds, alerting on failure (issue #59)
#[arg(long, value_name = "SECONDS")]
watch: Option<u64>,
},
/// Run the agent command directly with the given arguments (no process management)
Run {
/// Agent name or alias
agent: String,
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
#[arg(long, value_name = "PROVIDER")]
provider: Option<String>,
/// Skip the agent's sandbox profile (issue #79)
#[arg(long)]
no_sandbox: bool,
/// Run the agent inside a container (issue #58)
#[arg(long)]
container: bool,
/// Arguments passed through to the agent command
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
args: Vec<std::ffi::OsString>,
@@ -440,6 +594,241 @@ pub struct StartArgs {
/// Apply an environment profile (env + args, defined in the config)
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Provider to use for this run (issue #92): resolved from the registry
#[arg(long)]
pub provider: Option<String>,
/// Skip the agent's sandbox profile for this run (issue #79)
#[arg(long)]
pub no_sandbox: bool,
/// Start every member of a group simultaneously (issue #57)
#[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool,
/// Run the agent inside a container (issue #58)
#[arg(long, action = ArgAction::SetTrue)]
pub container: bool,
}
/// Arguments of the models command: inventory by default, prune with --prune.
#[derive(Args, Debug, Clone, Default)]
pub struct ModelsArgs {
/// Purge unused models instead of listing them
#[arg(long)]
pub prune: bool,
/// Only list what would be pruned, delete nothing
#[arg(long, conflicts_with = "yes")]
pub dry_run: bool,
/// Answer yes to the deletion prompt
#[arg(long)]
pub yes: bool,
/// Consider a model unused after N days (default: 30)
#[arg(long, value_name = "DAYS")]
pub days: Option<u64>,
}
/// Arguments of the lab command (issue #62).
#[derive(Args, Debug, Clone, Default)]
pub struct LabArgs {
/// Comma-separated agent names (or aliases) to benchmark
#[arg(long, value_name = "AGENTS", required_unless_present = "list")]
pub agents: Option<String>,
/// Task file (a bare name resolves under state_dir/lab/, .yaml appended)
#[arg(long, value_name = "FILE", required_unless_present = "list")]
pub task: Option<PathBuf>,
/// Run every agent concurrently instead of one after another
#[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool,
/// Override the task timeout in seconds
#[arg(long, value_name = "SECS")]
pub timeout: Option<u64>,
/// List the task files of the lab directory
#[arg(long, action = ArgAction::SetTrue)]
pub list: bool,
}
/// Arguments of the registry command (issue #77).
#[derive(Args, Debug, Clone, Default)]
pub struct RegistryArgs {
#[command(subcommand)]
pub sub: Option<RegistryCmd>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum RegistryCmd {
/// Prepare a catalog + manifest (source, version, author, sha256) for
/// publication on Gitea
Publish {
/// Path of the catalog YAML file
catalog: PathBuf,
/// Public URL of the catalog once pushed (recorded in the manifest)
#[arg(long)]
source: Option<String>,
/// Author name recorded in the manifest
#[arg(long)]
author: Option<String>,
/// Catalog version (default 1.0.0)
#[arg(long)]
version: Option<String>,
},
/// Search agents across the registered sources (settings.registry.sources)
Search {
/// Free-form keyword (agent name, description or tag)
query: String,
},
/// Install a catalog from the registry: manifest + checksum validation,
/// then an explicit trust decision for unknown sources
Install {
/// Catalog URL
url: String,
/// Skip the trust confirmation
#[arg(long)]
yes: bool,
},
/// List the registered sources
List,
}
/// Arguments of the plugins command (issue #75).
#[derive(Args, Debug, Clone, Default)]
pub struct PluginsArgs {
/// Run one plugin against a synthetic event and print its response
#[arg(long, value_name = "NAME")]
pub test: Option<String>,
}
/// Arguments of the web command (issue #54).
#[derive(Args, Debug, Clone, Default)]
pub struct WebArgs {
/// Port to listen on (default 7878)
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,
/// Do not open the browser automatically
#[arg(long, action = ArgAction::SetTrue)]
pub no_open: bool,
}
/// Arguments of am ai (issues #96 #97): natural language → shell action
/// via AIChat. The global flags --dry-run and --yes/-y apply as well.
#[derive(Args, Debug, Clone, Default)]
pub struct AiArgs {
/// The request in natural language
#[arg(value_name = "PROMPT", num_args = 1.., required = true)]
pub prompt: Vec<String>,
/// Execute the generated shell command (after the safety policy)
#[arg(short = 'e', long, action = ArgAction::SetTrue)]
pub exec: bool,
/// Files or directories passed as context (repeatable; default: the
/// current directory)
#[arg(short = 'f', long = "files", value_name = "PATH", action = ArgAction::Append)]
pub files: Vec<std::path::PathBuf>,
/// Provider of the registry used by aichat (env vars + model)
#[arg(long, value_name = "ID")]
pub provider: Option<String>,
/// Force a model (aichat --model)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Use one of the generated copilot roles (am-copilot, am-operator,
/// am-dev, am-do, am-analyst, am-orchestrator — issue v1.1.0 F3)
#[arg(long, value_name = "ROLE")]
pub role: Option<String>,
/// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is
/// handled by clap directly).
#[arg(skip)]
pub yes: bool,
/// REPL-only: --dry-run given on the REPL line.
#[arg(skip)]
pub dry_run: bool,
}
/// Arguments of am serve (issue #80).
#[derive(Args, Debug, Clone, Default)]
pub struct ServeArgs {
/// Bearer token required on every request (mandatory)
#[arg(long, value_name = "TOKEN")]
pub token: Option<String>,
/// Listen address (default 127.0.0.1)
#[arg(long, value_name = "HOST")]
pub host: Option<String>,
/// Listening port (default 8080)
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,
/// Per-IP request budget per minute (default 120)
#[arg(long, value_name = "N")]
pub rate_limit: Option<u32>,
}
/// Remote catalog subcommands (issues #60 #67).
#[derive(Subcommand, Debug, Clone)]
pub enum CatalogCmd {
/// Fetch the official catalog, show the diff and register it as an include
Update,
/// Register an external catalog (same YAML format) by URL
Add {
/// Catalog URL (http, https or file)
#[arg(value_name = "URL")]
url: String,
},
/// List the registered remote catalogs
List,
}
/// LLM provider registry subcommands (issue #88). The subcommand is
/// optional: `am providers` alone lists the registry.
#[derive(Args, Debug, Clone, Default)]
pub struct ProviderArgs {
#[command(subcommand)]
pub sub: Option<ProvidersCmd>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum ProvidersCmd {
/// List the registered providers (the default one is starred)
List,
/// Show one provider in detail (base URL, models, default model)
Show {
/// Provider name
name: String,
},
/// Register a provider, or update an existing one
Add {
/// Provider name (lowercase slug, e.g. openai)
name: String,
/// Base URL of the provider API
#[arg(long, value_name = "URL", required = true)]
base_url: String,
/// Model used by default for this provider
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Comma-separated model list
#[arg(long, value_name = "MODELS")]
models: Option<String>,
},
/// Remove a provider from the registry
Remove {
/// Provider name
name: String,
},
/// Store the provider API token in the OS keyring (shared by every
/// agent of this provider through the @secret cascade, issue #89)
SetToken {
/// Provider name
name: String,
/// Token value (never logged)
#[arg(long, value_name = "VALUE")]
value: String,
},
/// Check whether a provider token exists (the value is never shown)
Token {
/// Provider name
name: String,
},
/// Set the default provider
Default {
/// Provider name
name: String,
},
}
/// Environment profile subcommands (issue #40).
@@ -454,16 +843,66 @@ pub enum ProfileCmd {
},
}
/// Secret management subcommands.
/// 'am service' subcommands (issue #55).
#[derive(Subcommand, Debug, Clone)]
pub enum ServiceCmd {
/// Install the service unit (systemd / launchd / Task Scheduler)
Install {
/// Agent name
agent: String,
/// Enable autostart (boot/logon)
#[arg(long)]
autostart: bool,
},
/// Remove the service unit
Uninstall {
/// Agent name
agent: String,
},
/// Show whether the service is installed
Status {
/// Agent name
agent: String,
},
}
/// 'am schedule' subcommands (issue #56).
#[derive(Subcommand, Debug, Clone)]
pub enum ScheduleCmd {
/// Add a daily schedule (ex: 'am schedule add update --all')
Add {
/// Command to run, e.g. ["update", "--all"]
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "CMD...")]
command: Vec<String>,
/// Time of day HH:MM (default 08:00)
#[arg(long, value_name = "HH:MM")]
at: Option<String>,
},
/// List the schedules
List,
/// Remove a schedule by id
Remove {
/// Schedule id ('am schedule list' shows them)
id: String,
},
/// Run a schedule now (invoked by the OS task, issue #56)
Run {
/// Schedule id
id: String,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum SecretCmd {
/// Store a secret for an agent
/// Store a secret for an agent (or a provider, issue #89)
Set {
/// Secret name (the environment variable name)
name: String,
/// Agent it belongs to
/// Agent it belongs to (required unless --provider is given)
#[arg(long, value_name = "AGENT")]
agent: String,
agent: Option<String>,
/// Provider it belongs to (shared by every agent of this provider)
#[arg(long, value_name = "PROVIDER")]
provider: Option<String>,
/// Secret value (prefer --value over shell history; never logged)
#[arg(long, value_name = "VALUE")]
value: String,
@@ -472,9 +911,12 @@ pub enum SecretCmd {
Unset {
/// Secret name
name: String,
/// Agent it belongs to
/// Agent it belongs to (required unless --provider is given)
#[arg(long, value_name = "AGENT")]
agent: String,
agent: Option<String>,
/// Provider it belongs to
#[arg(long, value_name = "PROVIDER")]
provider: Option<String>,
},
/// List stored secret names (values are never shown)
List,
+17 -3
View File
@@ -3,6 +3,7 @@
//! launched: installed agents (managed + external), aliases and groups.
use super::*;
use std::collections::HashSet;
/// The completion pool: installed agents (managed + external), aliases and
/// groups when installed is true, the whole catalog otherwise.
@@ -10,16 +11,29 @@ pub fn agent_names(app: &App, installed: bool) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
if installed {
let probe = crate::probe::detect_externals(app);
let mut launchable: HashSet<String> = HashSet::new();
for a in app.catalog.agents() {
if app.state.get(&a.name).ok().flatten().is_some()
|| probe.path_of(&a.name).is_some()
{
launchable.insert(a.name.clone());
names.push(a.name.clone());
}
}
names.extend(app.config.aliases.keys().cloned());
for g in app.config.groups.keys() {
names.push(format!("group:{g}"));
for (alias, target) in &app.config.aliases {
let is_launchable = app
.catalog
.resolve(alias)
.map(|agent| launchable.contains(&agent.name))
.unwrap_or_else(|| launchable.contains(target));
if is_launchable {
names.push(alias.clone());
}
}
for (group, members) in &app.config.groups {
if members.iter().any(|m| launchable.contains(m)) {
names.push(format!("group:{group}"));
}
}
} else {
names.extend(app.catalog.agents().iter().map(|a| a.name.clone()));
+354
View File
@@ -0,0 +1,354 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
//! context files (default: the current directory). Exec mode generates the
//! shell command through aichat (`--code` — never executed by aichat),
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
//! default) and only executes after confirmation when required.
use crate::app::App;
use crate::cli::AiArgs;
use crate::commands::{require_agent, resolve_exec};
use crate::events::{Event, EventKind};
use crate::shell_ai::{Decision, SafetyMode};
use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
// Onboarding hint (v1.1.0 F1) — non-blocking: the AI commands propose
// `am setup` when no provider is configured, then continue anyway.
if crate::setup::needs_setup(app) {
app.log.info(crate::i18n::tr_in(
app.lang(),
"am n'est pas configuré — lancez am setup (provider + token)",
));
}
// F2 (v1.1.0): aichat must be INSTALLED (not only in the catalog) to
// power am ai. Offer the installation when missing.
if !aichat_installed(app) {
let ask_install = !app.cli.yes
&& app.confirm(crate::i18n::tr_in(
app.lang(),
"aichat est manquant. Installer ?",
))?;
if app.cli.yes || ask_install {
app.log.info(crate::i18n::tr_in(app.lang(), "Installation d'aichat…"));
crate::commands::install_cmd::run(app, "aichat", None, false, None, None, false)?;
} else {
bail!(crate::i18n::tr_in(
app.lang(),
"installez aichat: am install aichat — puis réessayez"
));
}
}
let prompt = args.prompt.join(" ");
// Context files: explicit --files wins, else the current directory
// (issue #96: `aichat -f . "<prompt>"`). The default directory is
// walked by am to skip noise/vendored/binary files — aichat itself
// filters nothing and fails on non-UTF-8 content (e.g. .git/index).
let files: Vec<String> = if args.files.is_empty() {
context_files(std::path::Path::new("."))
} else {
args.files
.iter()
.map(|p| p.display().to_string())
.collect()
};
// REPL per-line flags combine with the CLI globals.
let yes = app.cli.yes || args.yes;
let dry_run = app.cli.dry_run || args.dry_run;
let agent = require_agent(app, "aichat")?;
if !args.exec {
return chat_mode(app, agent, &prompt, &files, args, dry_run);
}
exec_mode(app, &prompt, &files, args, yes, dry_run)
}
/// True when the aichat binary is available (installed or on PATH).
/// Uses a direct PATH lookup — the probe cache would mask a shim added
/// mid-test and is meant for agent inventories, not runtime checks.
fn aichat_installed(app: &App) -> bool {
app.state.get("aichat").ok().flatten().is_some()
|| which::which("aichat").is_ok()
}
/// Walk `dir` and collect text files usable as aichat context (the
/// default `--files` when the flag is absent). aichat filters nothing
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
/// - skipped directories: `.git`, vendored/build noise (`target`,
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
/// Then the survivors are sorted and taken while a total budget of
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
/// at 40 files. Empty when nothing qualifies (aichat is called without
/// `-f`).
fn context_files(dir: &std::path::Path) -> Vec<String> {
const MAX_FILES: usize = 40;
const MAX_SIZE: u64 = 64 * 1024;
const TOTAL_BUDGET: u64 = 128 * 1024;
const SKIP_DIRS: &[&str] = &[
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
".cargo", "obj", "out", ".svn", ".hg",
];
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
walk(&path, out);
} else {
if name.starts_with(".env") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if meta.len() > MAX_SIZE {
continue;
}
// UTF-8 sniff on the first bytes — aichat rejects binaries.
let Ok(mut f) = std::fs::File::open(&path) else { continue };
use std::io::Read;
let mut head = Vec::new();
if f.take(8192).read_to_end(&mut head).is_err()
|| std::str::from_utf8(&head).is_err()
{
continue;
}
out.push((path.display().to_string(), meta.len()));
}
}
}
let mut found = Vec::new();
walk(dir, &mut found);
found.sort();
let mut files = Vec::new();
let mut total: u64 = 0;
for (p, len) in found {
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
break;
}
total += len;
files.push(p);
}
files
}
/// Build the aichat `--agent` arguments for the requested role. Without a
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
/// definition is missing the roles are regenerated on the fly, and a
/// generation failure degrades gracefully (no role). An explicit unknown
/// role is an error.
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
let role = role.unwrap_or("am-copilot");
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
let present = || def.exists() || legacy.exists();
if present() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
if role == "am-copilot" {
if let Err(e) = crate::roles::generate_all(app) {
app.log.verbose(&format!("roles generation skipped: {e:#}"));
}
if present() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
app.log.warn(crate::i18n::tr_in(
app.lang(),
"rôle 'am-copilot' introuvable — lancez am setup --roles",
));
return Ok(Vec::new());
}
bail!(crate::i18n::tr_in(
app.lang(),
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
)
.replace("{}", role));
}
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
/// the provider/model environment applied when requested.
fn chat_mode(
app: &App,
agent: &crate::config::AgentDef,
prompt: &str,
files: &[String],
args: &AiArgs,
dry_run: bool,
) -> Result<i32> {
let mut extra: Vec<String> = Vec::new();
extra.extend(role_args(app, args.role.as_deref())?);
for f in files {
extra.push("-f".to_string());
extra.push(f.clone());
}
extra.push(prompt.to_string());
let (p_args, p_env) =
crate::shell_ai::provider_env(app, args.provider.as_deref(), args.model.as_deref())?;
extra.extend(p_args);
if dry_run {
app.log.dry(&format!(
"would run aichat {} (conversationnel)",
extra.join(" ")
));
return Ok(0);
}
let exec = resolve_exec(app, agent, &extra, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {}",
exec.program,
exec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec!["mode=chat".to_string(), "executed=true".to_string()]),
);
Ok(status.code().unwrap_or(1))
}
/// Exec mode: generate the command (aichat --dry-run), classify it, apply
/// the safety policy, then execute through the shell when allowed.
fn exec_mode(
app: &App,
prompt: &str,
files: &[String],
args: &AiArgs,
yes: bool,
dry_run: bool,
) -> Result<i32> {
if dry_run {
app.log.dry(&format!(
"would generate & classify via aichat (exec) — commande non exécutée"
));
}
// Exec mode asks for the raw command: default to the am-do role
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
let cmd = crate::shell_ai::generate(
app,
prompt,
files,
args.provider.as_deref(),
args.model.as_deref(),
&role,
)?;
let settings = app
.config
.settings
.shell_ai
.clone()
.unwrap_or_default();
let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns);
let certainty = crate::shell_ai::estimate_certainty(&cmd, risk);
let mode = settings.safety_mode();
// Show the generated command and its classification (issue #97 UX).
println!("🔒 Commande générée : {}", cmd);
println!(" Risk : {}", risk.as_str());
println!(" Certainty: {}%", certainty);
println!(
" Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)",
mode.as_str()
);
let decision = crate::shell_ai::decide(mode, risk, dry_run, yes);
let lang = app.lang();
let mut executed = false;
match decision {
Decision::Abort => {
let msg = if dry_run {
crate::i18n::tr_in(lang, "dry-run : commande non exécutée")
} else {
crate::i18n::tr_in(
lang,
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes",
)
};
app.log.info(msg);
}
Decision::Ask => {
let ok = app.confirm(crate::i18n::tr_in(lang, "Exécuter ?"))?;
if ok {
executed = true;
} else {
app.log.info(crate::i18n::tr_in(lang, "annulé"));
}
}
Decision::Execute => {
executed = true;
}
}
let code = if executed {
app.log.info(&format!("exécution: {}", cmd));
let code = crate::shell_ai::execute(app, &cmd)?;
code
} else {
0
};
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec![
"mode=exec".to_string(),
format!("risk={}", risk.as_str()),
format!("certainty={certainty}"),
format!("executed={executed}"),
]),
);
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_dry_run_is_the_default_policy() {
// The embedded config does not set shell_ai -> dry-run default.
let s = crate::config::ShellAiSettings::default();
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
#[test]
fn context_files_skips_binary_noise_and_secrets() {
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".git")).unwrap();
std::fs::create_dir_all(base.join("target")).unwrap();
std::fs::create_dir_all(base.join("src")).unwrap();
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
let files = context_files(&base);
let names: Vec<String> = files
.iter()
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
let _ = std::fs::remove_dir_all(&base);
}
}
+332
View File
@@ -0,0 +1,332 @@
//! audit: who changed what, when (issue #63, axe 8). Config checksums are
//! recorded in state.json; 'am audit' compares them with the current files,
//! cross-references the event journal to date the changes, and flags edits
//! made outside of agent-manager.
use super::*;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use crate::state::ConfigChecksum;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Labels of the tracked config files.
const EMBEDDED: &str = "embedded";
const USER: &str = "user";
const LOCAL: &str = "local";
/// SHA-256 (hex) of a file's content, plus its mtime.
fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(text.as_bytes());
ConfigChecksum {
sha256: format!("{:x}", h.finalize()),
mtime_secs,
recorded_at: crate::installers::now_rfc3339(),
}
}
/// mtime in seconds since epoch.
fn mtime_secs(path: &std::path::Path) -> Option<u64> {
std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
/// The tracked config files: label -> (display path, content).
fn tracked_files(app: &App) -> Vec<(&'static str, String, Option<u64>)> {
let mut out = Vec::new();
// Embedded default catalog: content is baked into the binary.
out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None));
// User config file.
if let Some(dir) = &app.paths.config_dir {
let p = dir.join(crate::config::CONFIG_FILE_NAME);
if let Ok(text) = std::fs::read_to_string(&p) {
out.push((USER, text, mtime_secs(&p)));
}
}
// Local project config (agent-manager.yaml in the current directory).
let local = PathBuf::from(crate::config::CONFIG_FILE_NAME);
if let Ok(text) = std::fs::read_to_string(&local) {
out.push((LOCAL, text, mtime_secs(&local)));
}
out
}
/// Kinds of events that represent a modification of the configuration or
/// the fleet (they explain a checksum change).
fn is_mutating_kind(k: &EventKind) -> bool {
matches!(
k,
EventKind::Config
| EventKind::Install
| EventKind::Update
| EventKind::Uninstall
| EventKind::Annotate
| EventKind::Catalog
| EventKind::Model
| EventKind::Backup
| EventKind::Rollback
)
}
/// One audit row: a tracked file and its verdict.
#[derive(serde::Serialize)]
struct FileRow {
file: String,
label: &'static str,
status: &'static str, // ok | new | changed | deleted
changed_at: Option<String>,
via: Option<String>, // "am <kind>" or "manual (outside am)"
}
/// One audited journal event.
#[derive(serde::Serialize)]
struct EventRow {
when: String,
who: String, // "am <kind>"
what: String,
agent: Option<String>,
}
pub fn run(app: &App) -> Result<i32> {
let mut sf = app.state.load()?;
let baseline = std::mem::take(&mut sf.config_checksums);
// 1. Current checksums of every tracked file.
let files = tracked_files(app);
let mut rows: Vec<FileRow> = Vec::new();
let mut next_baseline: BTreeMap<String, ConfigChecksum> = BTreeMap::new();
for (label, text, mtime) in &files {
let current = checksum_of(text, mtime.unwrap_or(0));
let (status, via, changed_at) = match baseline.get(*label) {
None => ("new", Some("baseline recorded now".to_string()), None),
Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None),
Some(prev) => {
let at = mtime
.and_then(|t| {
chrono::DateTime::from_timestamp(t as i64, 0)
.map(|d| d.to_rfc3339())
})
.unwrap_or_else(|| "unknown".to_string());
(status_changed(prev, &current, app, &at), Some(at.clone()), Some(at))
}
};
let display = match *label {
EMBEDDED => "config.yaml (embedded default)".to_string(),
USER => user_config_display(app),
LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME),
_ => label.to_string(),
};
rows.push(FileRow {
file: display,
label,
status,
changed_at,
via,
});
next_baseline.insert(label.to_string(), current);
}
// 2. Cross-reference the journal: recent mutating events.
let events = crate::events::read_events(&app.events_dir(), 200);
let event_rows: Vec<EventRow> = events
.iter()
.filter(|e| is_mutating_kind(&e.kind))
.map(|e| EventRow {
when: e.ts.clone(),
who: format!("am {}", e.kind.as_str()),
what: format!(
"{}{}",
e.reason.clone().unwrap_or_default(),
if e.args.is_empty() {
String::new()
} else {
format!(" ({})", e.args.join(", "))
}
),
agent: e.agent.clone(),
})
.collect();
// 3. Persist the new baseline so the next audit compares against now.
sf.config_checksums = next_baseline;
app.state.save(&sf)?;
if app.json() {
print_json(&serde_json::json!({
"files": rows,
"events": event_rows,
"generated_at": crate::installers::now_rfc3339(),
}));
return Ok(0);
}
// 4. Human-readable report.
println!("config audit — who changed what, when\n");
let mut header = crate::tables::DataTable {
columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()],
rows: Vec::new(),
};
for r in &rows {
header.rows.push(vec![
crate::tables::Cell::str(&r.file),
crate::tables::Cell::str(status_style(r.status)),
crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")),
crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")),
]);
}
let width = crate::output::terminal_width().unwrap_or(120);
print!("{}", header.render_themed(app.theme(), app.color(), width));
println!("\nrecent changes (event journal):");
if event_rows.is_empty() {
println!(" none");
} else {
for e in event_rows.iter().take(15) {
println!(
" {} {} {}{}",
e.when,
e.who,
e.agent.as_deref().unwrap_or("-"),
if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) }
);
}
}
let changed = rows.iter().filter(|r| r.status == "changed").count();
if changed == 0 {
app.log.success("no configuration change detected since the last audit");
} else {
app.log
.warn(&format!("{changed} configuration file(s) changed since the last audit"));
}
Ok(0)
}
fn status_changed(
prev: &ConfigChecksum,
current: &ConfigChecksum,
app: &App,
at: &str,
) -> &'static str {
// A change can be explained by am itself (an event after the baseline).
let events = crate::events::read_events(&app.events_dir(), 100);
let explained = events.iter().any(|e| {
is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str()
});
// 'via' is filled by the caller from this: manual vs am.
let _ = (current, at);
if explained {
"changed"
} else {
"manual"
}
}
fn status_style(s: &str) -> String {
match s {
"ok" => "ok".to_string(),
"new" => "new".to_string(),
"changed" => "CHANGED".to_string(),
"manual" => "MANUAL".to_string(),
_ => s.to_string(),
}
}
fn user_config_display(app: &App) -> String {
app.paths
.config_dir
.as_ref()
.map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string())
.unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::state::{StateFile, StateStore};
use clap::Parser;
use std::io::Write;
fn test_app(tag: &str) -> (App, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"--no-color",
]);
let mut app = App::from_cli(cli).expect("app builds");
let state_file = dir.path().join("state.json");
app.paths.state_file = state_file.clone();
app.paths.config_dir = Some(dir.path().to_path_buf());
app.state = StateStore::new(state_file);
(app, dir)
}
#[test]
fn checksum_changes_when_content_changes() {
let a = checksum_of("version: \"1.0\"\n", 0);
let b = checksum_of("version: \"1.0\"\n# extra\n", 0);
assert_ne!(a.sha256, b.sha256, "different content -> different hash");
assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256);
}
#[test]
fn audit_records_baseline_and_detects_change() {
let (app, dir) = test_app("audit");
let user_cfg = dir.path().join("config.yaml");
// First run: everything is recorded as baseline.
run(&app).unwrap();
let sf = app.state.load().unwrap();
assert!(sf.config_checksums.contains_key(EMBEDDED));
assert!(sf.config_checksums.contains_key(USER));
// Touch the user config outside of am.
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&user_cfg)
.unwrap();
writeln!(f, "# modified by hand").unwrap();
drop(f);
// Second run: the change is flagged (status != ok).
run(&app).unwrap();
let sf2 = app.state.load().unwrap();
// The baseline was re-recorded, so a third run is clean.
run(&app).unwrap();
let sf3 = app.state.load().unwrap();
assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len());
assert!(sf3.config_checksums[USER].sha256.len() == 64);
}
#[test]
fn state_file_roundtrips_checksums() {
let mut sf = StateFile::default();
sf.config_checksums.insert(
"user".to_string(),
ConfigChecksum {
sha256: "ab".repeat(32),
mtime_secs: 42,
recorded_at: "2026-08-18T00:00:00Z".to_string(),
},
);
let text = serde_json::to_string(&sf).unwrap();
let back: StateFile = serde_json::from_str(&text).unwrap();
assert_eq!(back.config_checksums["user"].mtime_secs, 42);
}
}
+166
View File
@@ -0,0 +1,166 @@
//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9).
//! 'am catalog update' refreshes the official catalog and applies it as an
//! include; 'am catalog add <url>' registers any external catalog (same
//! YAML format) the same way. Everything goes through the probe-style
//! cache in catalog_remote so offline runs keep working.
use super::*;
use crate::catalog_remote::{self, CatalogDiff};
use crate::cli::CatalogCmd;
use crate::commands::config_cmd::register_include;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use anyhow::{anyhow, Result};
pub fn run(app: &App, cmd: &CatalogCmd) -> Result<i32> {
match cmd {
CatalogCmd::Update => update(app),
CatalogCmd::Add { url } => add(app, url),
CatalogCmd::List => list(app),
}
}
/// am catalog update: fetch the official catalog, show the diff against the
/// local catalog, ask for confirmation, then register the URL as an include.
fn update(app: &App) -> Result<i32> {
let url = catalog_remote::official_url(app);
app.log.info(&format!("fetching the official catalog: {url}"));
let text = catalog_remote::fetch(app, &url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
let diff = catalog_remote::diff_catalogs(&app.config, &remote);
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"added": diff.added,
"removed": diff.removed,
"changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::<Vec<_>>(),
}));
return Ok(0);
}
print_diff(&diff);
if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
app.log
.success(&format!("catalog is up to date ({} agents)", remote.agents.len()));
return Ok(0);
}
if app.dry_run() {
app.log.dry("would register the official catalog as an include");
return Ok(0);
}
if !app
.confirm(&format!(
"apply this catalog ({} agents, {} changes)?",
remote.agents.len(),
diff.added.len() + diff.removed.len() + diff.changed.len()
))?
{
app.log.info("catalog update cancelled");
return Ok(0);
}
register_include(app, &url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.clone()])
.with_reason("update"),
);
app.log.success(&format!(
"official catalog registered as an include ({url}) — it is effective from the next run"
));
Ok(0)
}
/// am catalog add <url>: validate and register an external catalog.
fn add(app: &App, url: &str) -> Result<i32> {
crate::download::validate_url(url)?;
if config_cmd::user_includes(app).iter().any(|i| i == url) {
app.log
.info(&format!("catalog {url} is already registered as an include"));
return Ok(0);
}
app.log.info(&format!("fetching catalog: {url}"));
// force: 'catalog add' must validate the current content of the URL,
// not a possibly stale cached copy.
let text = catalog_remote::fetch(app, url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"registered": false,
}));
return Ok(0);
}
if remote.agents.is_empty() {
return Err(anyhow!(
"the catalog at {url} defines no agents — refusing to register it"
));
}
if app.dry_run() {
app.log.dry(&format!(
"would register {url} ({:?} agents)",
remote.agents.len()
));
return Ok(0);
}
register_include(app, url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.to_string()])
.with_reason("add"),
);
app.log.success(&format!(
"catalog {url} registered ({} agents) — effective from the next run",
remote.agents.len()
));
Ok(0)
}
/// am catalog list: show the registered remote catalogs (includes).
/// The resolved config consumes its includes at load time, so this reads
/// the user config file directly (like 'config add').
fn list(app: &App) -> Result<i32> {
let urls: Vec<String> = config_cmd::user_includes(app)
.iter()
.filter(|i| {
i.starts_with("http://")
|| i.starts_with("https://")
|| i.starts_with("file://")
})
.cloned()
.collect();
if app.json() {
print_json(&urls);
return Ok(0);
}
if urls.is_empty() {
app.log.info(
"no remote catalog registered — 'am catalog add <url>' or 'am catalog update'",
);
return Ok(0);
}
println!("remote catalogs ({}):", urls.len());
for u in &urls {
println!(" - {u}");
}
Ok(0)
}
fn print_diff(diff: &CatalogDiff) {
if !diff.added.is_empty() {
println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", "));
}
if !diff.removed.is_empty() {
println!(
" - {} removed: {}",
diff.removed.len(),
diff.removed.join(", ")
);
}
if !diff.changed.is_empty() {
println!(" ~ {} changed run command(s):", diff.changed.len());
for (name, from, to) in &diff.changed {
println!(" {name}: '{from}' -> '{to}'");
}
}
}
+159 -15
View File
@@ -103,7 +103,35 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
/// configuration file, then validate the result.
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
let path = user_config_path(app)?;
let text = std::fs::read_to_string(&path)
write_dotted_key(&path, key, value)?;
reload_and_validate(&path)?;
app.log.success(&format!("{key} = {value}"));
Ok(0)
}
/// Persist one setting into the configuration file that is actually in
/// effect (the loaded origin), creating the user config file when none
/// exists. Used by the REPL 'theme <name>' command so a switched theme
/// survives a restart.
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
let path = active_config_path(app)?;
write_dotted_key(&path, key, value)?;
reload_and_validate(&path)
}
/// Path of the configuration file that is actually in effect: the loaded
/// origin when one exists, otherwise the user config file (created on
/// demand). Mutating commands write here so the change survives a reload.
pub(crate) fn active_config_path(app: &App) -> Result<PathBuf> {
match &app.config_origin {
Some(p) => Ok(p.clone()),
None => user_config_path(app),
}
}
/// Set one dotted key (e.g. settings.default_shell) in a YAML config file.
fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
@@ -128,16 +156,18 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
serde_yaml::Value::String(parts.last().unwrap().to_string()),
parsed,
);
std::fs::write(&path, serde_yaml::to_string(&root)?)
std::fs::write(path, serde_yaml::to_string(&root)?)
.with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
// Validate the new configuration end to end.
/// Reload a written config file to make sure the change stays valid.
pub(crate) fn reload_and_validate(path: &Path) -> Result<()> {
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
config::load(&cli).map_err(|e| {
anyhow!("the new value makes the configuration invalid: {e:#}")
})?;
app.log.success(&format!("{key} = {value}"));
Ok(0)
Ok(())
}
/// Parse a scalar: booleans and integers keep their type, everything else
@@ -160,7 +190,7 @@ fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
}
/// Ensure the user config file exists (creating a template when needed).
fn user_config_path(app: &App) -> Result<PathBuf> {
pub(crate) fn user_config_path(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
@@ -194,7 +224,42 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let dest = dir.join(&base);
std::fs::copy(file, &dest)
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
register_include(app, &base)?;
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
user_config_path(app)?.display()
));
Ok(0)
}
/// Read the `include:` entries of the user config file (they are consumed
/// at load time, so the resolved config no longer lists them).
pub fn user_includes(app: &App) -> Vec<String> {
let Some(cfg_path) = user_config_path_if_any(app) else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
return Vec::new();
};
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
return Vec::new();
};
value
.get("include")
.and_then(|i| i.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
/// Add one entry to the `include:` list of the user config file (creating
/// the file with a template when it does not exist). Used by 'config add'
/// (local files) and 'catalog add/update' (remote URLs, issues #60 #67).
pub fn register_include(app: &App, entry: &str) -> Result<()> {
let cfg_path = user_config_path(app)?;
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
@@ -204,7 +269,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(base.clone());
let entry = serde_yaml::Value::String(entry.to_string());
if !seq.contains(&entry) {
seq.push(entry);
}
@@ -213,7 +278,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
base.clone(),
entry.to_string(),
)]),
);
}
@@ -224,14 +289,93 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
_ => {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
format!("version: \"1.0\"\ninclude:\n - {entry}\n"),
)?;
}
}
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
cfg_path.display()
));
Ok(0)
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
/// App whose active config is a file inside a leaked TempDir (the same
/// pattern as profile_cmd's tests: the guard would delete the directory
/// when the helper returns).
fn app_with_config(text: &str) -> (App, PathBuf) {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, text).unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
(app, cfg)
}
#[test]
fn persist_setting_writes_into_the_active_config() {
let (app, cfg) = app_with_config("version: \"1.0\"
settings:
theme: default
agents: []
");
persist_setting(&app, "settings.theme", "ocean").unwrap();
let text = std::fs::read_to_string(&cfg).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap();
assert_eq!(value["settings"]["theme"], "ocean");
}
#[test]
fn persisted_theme_is_used_on_next_start() {
let (app, cfg) = app_with_config("version: \"1.0\"
settings:
theme: default
agents: []
");
persist_setting(&app, "settings.theme", "ocean").unwrap();
// A fresh start reads settings.theme from the config file.
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
assert_eq!(reloaded.theme().name, "ocean");
}
#[test]
fn persist_setting_creates_the_user_config_when_none_exists() {
let (mut app, _cfg) = app_with_config("version: \"1.0\"
agents: []
");
// Simulate a run without any user/local config: no origin.
app.config_origin = None;
persist_setting(&app, "settings.theme", "mono").unwrap();
let user_file = app.paths.config_dir.as_ref().unwrap().join(config::CONFIG_FILE_NAME);
assert!(user_file.is_file(), "user config was not created");
let text = std::fs::read_to_string(&user_file).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap();
assert_eq!(value["settings"]["theme"], "mono");
}
#[test]
fn write_dotted_key_descends_into_nested_mappings() {
let guard = tempfile::tempdir().unwrap();
let path = guard.path().join("c.yaml");
std::fs::write(&path, "version: \"1.0\"
agents: []
").unwrap();
write_dotted_key(&path, "settings.default_shell", "pwsh").unwrap();
let value: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(value["settings"]["default_shell"], "pwsh");
assert!(value["agents"].is_sequence());
}
}
+42 -1
View File
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
use anyhow::Result;
pub fn run(app: &App, fix: bool) -> Result<i32> {
pub fn run(app: &App, fix: bool, watch: Option<u64>) -> Result<i32> {
if let Some(interval) = watch {
return watch_loop(app, interval.max(1));
}
if fix {
let repaired = doctor::fix(app)?;
for r in &repaired {
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
}
Ok(if report.ok() { 0 } else { 1 })
}
/// Issue #59: re-run the doctor checks every N seconds; alert (desktop
/// notification + event) on the first failure, recover silently. Clean
/// Ctrl-C via ctrlc.
fn watch_loop(app: &App, interval: u64) -> Result<i32> {
app.log
.info(&format!("watching every {interval}s (Ctrl-C to stop)"));
let mut was_ok = true;
loop {
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
let ok = report.ok();
if !ok && was_ok {
// First failure: alert.
app.emit(
&Event::now(EventKind::Doctor)
.with_exit_code(1)
.with_reason(format!("{} problem(s) found", report.problems())),
);
app.log.error(&format!(
"{} problem(s) found — 'am doctor --fix' repairs what it can",
report.problems()
));
let _ = crate::process::desktop_notify(
"agent-manager",
&format!("doctor: {} problem(s)", report.problems()),
);
was_ok = false;
} else if ok && !was_ok {
app.log.success("environment recovered");
was_ok = true;
} else if !ok {
app.log
.warn(&format!("still {} problem(s)", report.problems()));
}
std::thread::sleep(std::time::Duration::from_secs(interval));
}
}
+45
View File
@@ -42,6 +42,37 @@ fn rerun_entry(app: &App, n: usize) -> Result<i32> {
}
}
/// Filter history entries; returns them most recent first.
/// Parse "12..25" (inclusive bounds, 1-based) or a single "12".
fn parse_range(r: &str) -> Result<(usize, usize)> {
let r = r.trim();
if r.is_empty() {
anyhow::bail!("empty range — usage: am history <from>..<to> --save <file>");
}
if let Some((a, b)) = r.split_once("..") {
let from = a
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
let to = b
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if from == 0 || to == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((from, to))
} else {
let n = r
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if n == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((n, n))
}
}
/// Filter history entries; returns them most recent first.
pub fn filter(
app: &App,
@@ -96,11 +127,25 @@ pub fn run(
session: Option<&str>,
limit: usize,
rerun: Option<usize>,
range: Option<&str>,
save: Option<&Path>,
) -> Result<i32> {
history::migrate(app)?;
if let Some(n) = rerun {
return rerun_entry(app, n);
}
// Issue #51: 'am history 12..25 --save <file>' exports a playbook.
if let Some(r) = range {
let file = save.ok_or_else(|| anyhow!("--save <file> is required with a range"))?;
let (from, to) = parse_range(r)?;
let (n, path) = crate::playbook::export_range(app, from, to, file)?;
app.log
.success(&format!("playbook saved: {} ({n} step(s))", path.display()));
return Ok(0);
}
if save.is_some() {
anyhow::bail!("usage: am history <from>..<to> --save <file>");
}
let rows = filter(app, kind, cwd, search, failed, session, limit);
if app.json() {
let payload: Vec<serde_json::Value> = rows
+54 -29
View File
@@ -1,5 +1,6 @@
//! init: generate a local agent-manager.yaml for the current directory,
//! based on the detected technology stack.
//! based on the detected technology stack or an explicit template
//! (issue #52: 'am init --template web|python|rust|cli').
use super::*;
use crate::context;
@@ -7,8 +8,9 @@ use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
/// Generate a local config file in dir; refuses to overwrite unless force.
/// Returns the written path.
pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
/// `template` renders a pre-filled config for a stack ('am init --template');
/// None falls back on the automatic stack detection. Returns the written path.
pub fn generate(dir: &Path, force: bool, template: Option<&str>) -> Result<PathBuf> {
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
if target.exists() && !force {
return Err(anyhow!(
@@ -16,34 +18,51 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
target.display()
));
}
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
let stack = if ctx.stack.is_empty() {
"aucune détectée".to_string()
let project = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
let dir_str = dir.to_string_lossy().to_string();
let content = if let Some(t) = template {
crate::templates::render(t, &project, &dir_str)?
} else {
ctx.stack.join(", ")
};
let content = format!(
"# Généré par 'am init' le {date}
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
let stack = if ctx.stack.is_empty() {
crate::i18n::tr("aucune détectée").to_string()
} else {
ctx.stack.join(", ")
};
format!(
"# Généré par 'am init' le {date}
# Pile détectée : {stack}
version: \"1.0\"
groups:
dev: [{members}]
agents: []
",
date = crate::installers::now_rfc3339(),
);
date = crate::installers::now_rfc3339(),
)
};
std::fs::write(&target, content)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
pub fn run(app: &App, force: bool) -> Result<i32> {
pub fn run(app: &App, force: bool, template: Option<&str>) -> Result<i32> {
// 'am init --template list' prints the available templates.
if template == Some("list") {
println!("available templates:");
for (name, desc) in crate::templates::TEMPLATES {
println!(" {name:<8} {desc}");
}
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!(
"would generate {} in the current directory",
@@ -53,16 +72,22 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
}
let cwd = std::env::current_dir().context("cannot determine the current directory")?;
let ctx = context::detect(&cwd);
let path = generate(&cwd, force)?;
app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
"aucune détectée".to_string()
} else {
ctx.stack.join(", ")
}
));
let path = generate(&cwd, force, template)?;
match template {
Some(t) => app.log.success(&format!(
"wrote {} (template: {t})",
path.display()
)),
None => app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
crate::i18n::tr("aucune détectée").to_string()
} else {
ctx.stack.join(", ")
}
)),
}
app.log.info(&format!(
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
));
+120 -2
View File
@@ -8,7 +8,15 @@ use crate::runner::SystemRunner;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -> Result<i32> {
pub fn run(
app: &App,
agent_name: &str,
method_sel: Option<&str>,
force: bool,
provider: Option<&str>,
model: Option<&str>,
no_config: bool,
) -> Result<i32> {
let agent = require_agent(app, agent_name)?;
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
@@ -38,6 +46,20 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
let methods = spec.all_methods();
let method = select_method(&methods, method_sel)?;
// Provider/model resolution (issue #90): flag > agent.provider >
// settings.default_provider ; flag --model > agent.model >
// provider.default_model. Displayed in the summary (and by --dry-run).
let resolved = resolve_provider_config(app, agent, provider, model)?;
if no_config {
app.log
.info(crate::i18n::tr("--no-config : aucune configuration post-install"));
} else if !spec.configurable {
app.log.info(&crate::tr_fmt!(
"{} ne prend aucune configuration (install.configurable: false)",
agent.name
));
}
// Already installed?
let existing = app.state.get(&agent.name).ok().flatten();
if existing.is_some() && !force && !app.dry_run() {
@@ -135,13 +157,28 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
}
}
// Post-install provider configuration (issue #91): write the agent
// config files with the resolved provider/model. Skipped on --no-config
// and on agents declaring install.configurable: false; never reached in
// dry-run (returns earlier).
if !no_config && spec.configurable {
crate::agent_config::apply_post_install(app, agent, &resolved)?;
}
let entry = installers::make_entry(agent, method, &outcome, app);
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&entry)?;
let mut args = vec![format!("method={}", method.kind)];
if let Some((p, m)) = &resolved {
args.push(format!("provider={p}"));
if let Some(m) = m {
args.push(format!("model={m}"));
}
}
app.emit(
&Event::now(EventKind::Install)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
.with_args(args),
);
crate::hooks::run_hooks(
app,
@@ -161,3 +198,84 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
}
Ok(0)
}
/// Resolve the provider + model of this install (issue #90) and print what
/// will be configured. Returns (provider, model) when resolved.
///
/// Validation rules:
/// - an explicit `--provider` that is not registered → error listing the
/// registry (the user asked for it by name);
/// - an agent-declared provider that is not registered → warning only
/// (catalog metadata may reference a provider the user has not added yet);
/// - a model absent from the provider's model list → warning, not blocking.
fn resolve_provider_config(
app: &App,
agent: &AgentDef,
flag_provider: Option<&str>,
flag_model: Option<&str>,
) -> Result<Option<(String, Option<String>)>> {
let flag_p = flag_provider.filter(|p| !p.is_empty());
let agent_p = crate::agent_config::provider_pref(agent).filter(|p| !p.is_empty());
// Explicit provider requested but not registered: hard error.
if let Some(p) = flag_p {
if crate::providers::get(&app.config, p).is_none() {
let known = crate::providers::names(&app.config);
let hint = if known.is_empty() {
"am providers add <nom> --base-url <url>".to_string()
} else {
known.join(", ")
};
bail!(crate::tr_fmt!(
"provider '{}' inconnu — providers enregistrés: {}",
p,
hint
));
}
}
// Agent-declared provider not registered: warn (unless the user already
// overrode it with --provider, which was validated above).
if let Some(p) = agent_p {
if flag_p.is_none() && crate::providers::get(&app.config, p).is_none() {
app.log.warn(&crate::tr_fmt!(
"provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url <url>",
p,
agent.name,
p
));
return Ok(None);
}
}
let Some((pname, def, model)) = crate::providers::resolve_for(
&app.config,
crate::agent_config::provider_pref(agent),
agent.model.as_deref(),
flag_provider,
flag_model,
) else {
app.log.info(crate::i18n::tr(
"aucun provider configuré — voir: am providers add <nom> --base-url <url>",
));
return Ok(None);
};
// Model not in the provider's list: warn, keep going.
if let Some(m) = model {
if !def.models.is_empty() && !def.models.iter().any(|x| x == m) {
app.log.warn(&crate::tr_fmt!(
"modèle '{}' absent de la liste du provider '{}' — modèles: {}",
m,
pname,
def.models.join(", ")
));
}
}
let model_label = model.unwrap_or("-");
app.log.info(&crate::tr_fmt!(
"configuré avec le provider {} (modèle {})",
pname,
model_label
));
Ok(Some((pname.to_string(), model.map(String::from))))
}
+101
View File
@@ -0,0 +1,101 @@
//! lab: benchmark the same task on several agents (issue #62).
use super::*;
use crate::cli::LabArgs;
use crate::lab::{self, LabReport};
use anyhow::Result;
pub fn run(app: &App, args: &LabArgs) -> Result<i32> {
if args.list {
let tasks = lab::list_tasks(app);
if app.json() {
let names: Vec<String> = tasks.iter().map(|p| p.display().to_string()).collect();
crate::output::print_json(&names);
return Ok(0);
}
if tasks.is_empty() {
app.log.info(&format!(
"no tasks in {} — write a task.yaml (name, args, timeout) or run 'lab --agents a,b --task hello'",
lab::dir(app).display()
));
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["TASK"]);
for p in tasks {
table.row(vec![p.display().to_string()]);
}
print!("{}", table.render());
return Ok(0);
}
let path = match args.task.as_deref() {
Some(p) => lab::resolve_task(app, p),
None => anyhow::bail!("--task <file> is required"),
};
if !path.exists() {
anyhow::bail!(
"task not found: {} — write it or use 'lab --list' to see the tasks of {}",
path.display(),
lab::dir(app).display()
);
}
let task = lab::load_task(&path)?;
let agents = lab::split_agents(args.agents.as_deref().unwrap_or(""));
if agents.is_empty() {
anyhow::bail!(
"--agents expects at least one agent name, got '{}'",
args.agents.as_deref().unwrap_or("")
);
}
app.log.info(&format!(
"lab '{}' — {} agent(s), {}",
task.name,
agents.len(),
if args.parallel { "parallèle" } else { "séquentiel" }
));
let report = lab::run_benchmark(app, &agents, &task, args.parallel, args.timeout)?;
if app.json() {
crate::output::print_json(&report);
} else {
print_report(app, &report);
}
// Exit code: 0 when every run succeeded, 1 otherwise (CI-friendly).
Ok(if report.runs.iter().all(|r| r.status == "ok") {
0
} else {
1
})
}
fn print_report(app: &App, report: &LabReport) {
let mut table = crate::output::Table::new(vec![
"AGENT", "STATUS", "EXIT", "DURATION", "COST", "OUTPUT",
]);
for r in &report.runs {
table.row(vec![
r.agent.clone(),
r.status.clone(),
r.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "-".into()),
format!("{:.2}s", r.duration_ms as f64 / 1000.0),
r.cost_usd
.map(|c| format!("${c:.4}"))
.unwrap_or_else(|| "-".into()),
crate::output::truncate(r.output.lines().next().unwrap_or(""), 40),
]);
}
print!("{}", table.render());
let s = &report.summary;
let mut line = format!(
"{} run(s) — {} ok, {} failed, {} timeout",
s.runs, s.ok, s.failed, s.timeout
);
if let Some(f) = &s.fastest {
line.push_str(&format!(" — fastest: {f}"));
}
if let Some(c) = &s.cheapest {
line.push_str(&format!(" — cheapest: {c}"));
}
app.log.info(&line);
}
+22 -8
View File
@@ -75,14 +75,14 @@ pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
}
#[derive(Serialize)]
struct LogRow {
ts: String,
kind: String,
agent: Option<String>,
pid: Option<u32>,
exit_code: Option<i32>,
duration_s: Option<u64>,
session: Option<String>,
pub struct LogRow {
pub ts: String,
pub kind: String,
pub agent: Option<String>,
pub pid: Option<u32>,
pub exit_code: Option<i32>,
pub duration_s: Option<u64>,
pub session: Option<String>,
}
impl From<&Event> for LogRow {
@@ -99,6 +99,20 @@ impl From<&Event> for LogRow {
}
}
/// Rows for the web API (issue #54): same shape as `am log --json`.
pub fn rows(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<Vec<LogRow>> {
Ok(filter_events(app, agent, kinds, since, limit)?
.iter()
.map(LogRow::from)
.collect())
}
/// Chars of text after byte offset seen (UTF-8 safe).
pub fn text_after(text: &str, seen: usize) -> String {
text.char_indices()
+338 -3
View File
@@ -1,11 +1,15 @@
//! man: generate roff man pages from the clap definition (issue #41).
//! 'am man' prints am.1 for the whole CLI; 'am man <command>' prints one
//! page; '--output DIR' writes am.1 plus one am-<command>.1 per command.
//!
//! On a terminal the page is rendered to readable, themed text (there is no
//! man viewer on Windows); when stdout is piped the raw roff source is kept
//! so 'am man | man -l -' and scripts keep working unchanged.
use super::*;
use anyhow::Context;
use clap::CommandFactory;
use std::io::Write;
use std::io::{IsTerminal, Write};
use std::path::Path;
pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i32> {
@@ -30,7 +34,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
dir.join(format!("am-{name}.1")).display()
));
}
None => man.render(&mut std::io::stdout())?,
None => print_page(app, &man)?,
}
}
None => match output {
@@ -51,7 +55,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
cmd.get_subcommands().count()
));
}
None => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
None => print_page(app, &clap_mangen::Man::new(cmd))?,
},
}
Ok(0)
@@ -68,3 +72,334 @@ fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> {
file.flush().ok();
Ok(())
}
/// Print one man page: raw roff when stdout is piped (scripts, 'man -l -'),
/// a readable themed rendering when it is a terminal.
fn print_page(app: &App, man: &clap_mangen::Man) -> Result<()> {
let mut buf: Vec<u8> = Vec::new();
man.render(&mut buf)
.context("cannot render the man page")?;
let roff = String::from_utf8_lossy(&buf);
if std::io::stdout().is_terminal() {
print!("{}", roff_to_text(&roff, app.theme(), app.color()));
} else {
std::io::stdout().write_all(roff.as_bytes())?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Miniature roff viewer: renders the groff man macro subset emitted by
// clap_mangen (.TH .SH .SS .PP .TP .IP .B .I .BR .RB .BI .IB .IR .RI .RS
// .RE .nf .fi ...) to plain, themed text.
// ---------------------------------------------------------------------------
const IND_TERM: &str = " ";
const IND_BODY: &str = " ";
/// Convert the roff source of a man page into readable text, coloured with
/// the active theme when color is on.
pub fn roff_to_text(roff: &str, theme: &crate::theme::Theme, color: bool) -> String {
let mut out = String::new();
let mut level = 0usize; // .RS/.RE nesting
let mut in_tp = false; // inside a .TP definition list
let mut tp_term: Option<String> = None; // the pending term of a .TP
let mut tp_printed = false; // term emitted, further lines are bodies
for raw in roff.lines() {
let line = raw.trim_end();
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with(".\\\"") {
continue;
}
// Groff conditionals defining \*(Aq — not needed by the renderer.
if trimmed.starts_with(".ie ") || trimmed.starts_with(".el ") {
continue;
}
if let Some(rest) = trimmed.strip_prefix('.') {
let (name, args) = match rest.split_once(char::is_whitespace) {
Some((n, a)) => (n, a.trim()),
None => (rest, ""),
};
// Any other macro closes a pending .TP list.
if let Some(term) = tp_term.take() {
out.push_str(&format!("{IND_TERM}{term}\n"));
}
in_tp = false;
tp_printed = false;
match name {
"TH" => {
let mut parts = args.split_whitespace();
let title = parts.next().unwrap_or("am");
let sec = parts.next().unwrap_or("1");
let meta = parts.collect::<Vec<_>>().join(" ");
let meta = meta.trim_matches('"').to_string();
let mut line = theme.acc(&format!("{title}({sec})"));
if !meta.is_empty() {
line.push_str(&format!(" {}", theme.dimmed(&meta)));
}
out.push_str(&format!("{line}\n\n"));
}
"SH" => {
out.push('\n');
let title = conv(args, theme, color);
out.push_str(&format!(
"{}\n",
if color {
theme.hdr(&format!("▸ {title}"))
} else {
format!("▸ {title}")
}
));
}
"SS" => {
out.push('\n');
let title = conv(args, theme, color);
out.push_str(&format!(
"{}\n",
if color {
theme.inf(&format!("· {title}"))
} else {
format!("· {title}")
}
));
}
"PP" | "P" | "sp" => out.push('\n'),
"br" => out.push('\n'),
"TP" => {
in_tp = true;
tp_term = None;
tp_printed = false;
}
"IP" => {
// Indented paragraph, often a bullet (see also lists).
in_tp = true;
tp_printed = true;
out.push_str(&format!("{IND_TERM}{}\n", conv(args, theme, color)));
}
"B" => out.push_str(&format!("{IND_TERM}{}\n", bold(conv(args, theme, color), color))),
"I" => out.push_str(&format!(
"{IND_TERM}{}\n",
if color {
theme.inf(&conv(args, theme, color))
} else {
conv(args, theme, color)
}
)),
"BR" | "RB" | "BI" | "IB" | "IR" | "RI" => {
let mut parts = args.splitn(2, char::is_whitespace);
let a = parts.next().unwrap_or("");
let b = parts.next().unwrap_or("");
let (bold_first, italic_second) = match name {
"BR" | "BI" | "IB" | "IR" => (true, matches!(name, "BI" | "IB")),
_ => (false, false),
};
let left = if bold_first {
bold(conv(a, theme, color), color)
} else {
conv(a, theme, color)
};
let right = if italic_second {
if color {
theme.inf(&conv(b, theme, color))
} else {
conv(b, theme, color)
}
} else {
conv(b, theme, color)
};
out.push_str(&format!("{IND_TERM}{left} {right}\n"));
}
"RS" => level += 1,
"RE" => level = level.saturating_sub(1),
// Filling/formatting directives that do not affect this
// line-based rendering.
"nf" | "fi" | "ad" | "hy" | "in" | "ds" | "EX" | "EE" => {}
other => {
// Unknown macro: keep its arguments as plain text when
// there is something to show.
if !args.is_empty() {
out.push_str(&format!("{}{}\n", indent(level), conv(args, theme, color)));
}
let _ = other;
}
}
continue;
}
// Plain text line: the first line after a .TP is the term, the
// following ones its body. The term is emitted as soon as the
// first body line arrives, so terms and bodies stay in order.
let text = conv(trimmed, theme, color);
if in_tp && !tp_printed {
if tp_term.is_none() {
tp_term = Some(text);
} else {
if let Some(term) = tp_term.take() {
out.push_str(&format!("{IND_TERM}{term}\n"));
}
out.push_str(&format!("{IND_BODY}{text}\n"));
tp_printed = true;
}
} else if in_tp {
out.push_str(&format!("{IND_BODY}{text}\n"));
} else {
out.push_str(&format!("{}{}\n", indent(level), text));
}
}
if let Some(term) = tp_term.take() {
out.push_str(&format!("{IND_TERM}{term}\n"));
}
out
}
/// Indentation for .RS/.RE nesting (plus a small base margin).
fn indent(level: usize) -> String {
format!("{}{}", " ".repeat(level), " ")
}
/// Bold text: ANSI bold when colors are on, plain otherwise.
fn bold(s: String, color: bool) -> String {
if color {
format!("\x1b[1m{s}\x1b[0m")
} else {
s
}
}
/// Convert roff inline escapes to text: \\fB/\\fI/\\fR font switches,
/// \\*(Aq, \\- (hyphen), \\(bu (bullet), \\& and friends.
fn conv(s: &str, theme: &crate::theme::Theme, color: bool) -> String {
let mut out = String::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] != '\\' {
out.push(chars[i]);
i += 1;
continue;
}
let Some(next) = chars.get(i + 1).copied() else {
out.push('\\');
break;
};
match next {
'f' => {
let mode = chars.get(i + 2).copied().unwrap_or('P');
if color {
match mode {
'B' => out.push_str("\x1b[1m"),
'I' => out.push_str(&format!("\x1b[{}m", theme.info)),
_ => out.push_str("\x1b[0m"),
}
}
i += 3;
}
'(' => {
let a = chars.get(i + 2).copied().unwrap_or(' ');
let d = chars.get(i + 3).copied().unwrap_or(' ');
let two = format!("{a}{d}");
out.push_str(match two.as_str() {
"aq" => "'",
"bu" => "•",
"em" => "—",
"lq" => "\u{201c}",
"rq" => "\u{201d}",
_ => two.as_str(),
});
i += 4;
}
'*' => {
// \*(xx : the '(' opens the two-character string name.
let a = chars.get(i + 3).copied().unwrap_or(' ');
let d = chars.get(i + 4).copied().unwrap_or(' ');
let name = format!("{a}{d}");
out.push_str(match name.as_str() {
"Aq" | "Sq" => "'",
_ => "",
});
i += 5;
}
'-' => {
out.push('-');
i += 2;
}
'&' => i += 2,
'e' => {
out.push('\\');
i += 2;
}
' ' => {
out.push(' ');
i += 2;
}
'\\' => {
out.push('\\');
i += 2;
}
_ => {
out.push('\\');
i += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::default_theme;
fn plain(roff: &str) -> String {
roff_to_text(roff, default_theme(), false)
}
#[test]
fn renders_sections_terms_and_bodies() {
let text = plain(
".TH am 1 \"am 0.4.3\"\n\
.SH NAME\n\
am \\- agent manager\n\
.SH OPTIONS\n\
.TP\n\
\\fB\\-c\\fR, \\fB\\-\\-config\\fR \\fI<FILE>\\fR\n\
Use an alternate configuration file\n",
);
assert!(text.contains("am(1)"), "{text}");
assert!(text.contains("▸ NAME"), "{text}");
assert!(text.contains("▸ OPTIONS"), "{text}");
assert!(text.contains("am - agent manager"), "{text}");
assert!(text.contains("--config"), "{text}");
assert!(text.contains("Use an alternate configuration file"), "{text}");
assert!(!text.contains("\\fB"), "font escapes must be converted: {text}");
assert!(!text.contains(".SH"), "macros must be removed: {text}");
}
#[test]
fn converts_special_characters() {
let t = default_theme();
assert_eq!(conv("\\*(Aqhello\\*(Aq", t, false), "'hello'");
assert_eq!(conv("\\-\\-dry\\-run", t, false), "--dry-run");
assert_eq!(conv("\\(bu", t, false), "•");
assert_eq!(conv("\\&x", t, false), "x");
assert_eq!(conv("a\\e\\eb", t, false), "a\\\\b");
}
#[test]
fn colors_bold_and_italic_segments_when_enabled() {
let t = default_theme();
let s = conv("\\fBam\\fR \\fIvalue\\fR", t, true);
assert!(s.contains("\x1b[1m"), "{s}");
assert!(s.contains(&format!("\x1b[{}m", t.info)), "{s}");
assert!(s.contains("\x1b[0m"), "{s}");
// Without colors the same text is plain.
assert_eq!(conv("\\fBam\\fR \\fIvalue\\fR", t, false), "am value");
}
#[test]
fn tp_term_is_flushed_at_the_next_macro() {
let text = plain(".TP\n\\fBam\\fR\nbody line\n.PP\nnext paragraph\n");
assert!(text.contains(" am\n"), "{text}");
assert!(text.contains(" body line"), "{text}");
assert!(text.contains("next paragraph"), "{text}");
}
}
+501
View File
@@ -0,0 +1,501 @@
//! migrate: transfer the installation and state from machine A to machine B
//! (issue #68). `am migrate export` bundles config, state, journal, history,
//! custom catalogs and backups into a single .amx file with a manifest of
//! hashes; `am migrate import` restores it and runs `am doctor` afterwards.
//! Absolute paths are neutralized ({{STATE_DIR}} / {{HOME}} placeholders) so
//! the bundle is portable. No runtime dependency: the bundle format is a
//! JSON manifest line followed by concatenated blobs.
use super::*;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
pub const BUNDLE_FORMAT: &str = "am-migrate-bundle";
pub const BUNDLE_VERSION: u32 = 1;
/// One file inside the bundle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleFile {
/// Relative path inside the state layout ("state.json", "events/...", ...).
pub path: String,
pub size: u64,
pub sha256: String,
/// Byte offset of the blob inside the bundle.
pub offset: u64,
}
/// Bundle header (first line of the file, then the blobs).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleManifest {
pub format: String,
pub version: u32,
pub created: String,
pub am_version: String,
pub files: Vec<BundleFile>,
}
/// sha256 hex of a byte slice.
pub fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data);
let out = h.finalize();
out.iter().map(|b| format!("{b:02x}")).collect()
}
/// Neutralize absolute paths of machine A into portable placeholders.
/// The source paths are replaced only when they match the current machine.
pub fn neutralize(text: &str, state_dir: &Path, home: &Path) -> String {
let mut out = text.to_string();
let sd = state_dir.to_string_lossy().to_string();
let h = home.to_string_lossy().to_string();
out = out.replace(&sd, "{{STATE_DIR}}");
out = out.replace(&h, "{{HOME}}");
out
}
/// Restore the placeholders with machine B paths.
pub fn deneutralize(text: &str, state_dir: &Path, home: &Path) -> String {
let mut out = text.to_string();
out = out.replace("{{STATE_DIR}}", &state_dir.to_string_lossy());
out = out.replace("{{HOME}}", &home.to_string_lossy());
out
}
/// Collect the files to bundle: config, state, journal, history, catalog
/// cache, backups. Returns (relative path, absolute path, needs path
/// neutralization).
fn collect_files(app: &App) -> Vec<(String, PathBuf, bool)> {
let state = app.events_dir();
let mut out: Vec<(String, PathBuf, bool)> = Vec::new();
// Active config file (when it exists on disk).
if let Some(dir) = &app.paths.config_dir {
let cfg = dir.join(crate::config::CONFIG_FILE_NAME);
if cfg.exists() {
out.push(("config.yaml".to_string(), cfg, true));
}
}
// State file.
let sf = app.paths.state_file.clone();
if sf.exists() {
out.push(("state.json".to_string(), sf, true));
}
// Journal events.
let events = state.join("events");
if let Ok(entries) = std::fs::read_dir(&events) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
let rel = format!(
"events/{}",
p.file_name().unwrap_or_default().to_string_lossy()
);
out.push((rel, p, true));
}
}
} else if let Ok(entries) = std::fs::read_dir(&state) {
// Older layout: journal files sit directly in the state directory.
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
if name.starts_with("events-") && name.ends_with(".jsonl") {
out.push((name, p, true));
}
}
}
// History files (per-session JSONL).
let history = state.join("history");
if let Ok(entries) = std::fs::read_dir(&history) {
for e in entries.flatten() {
let p = e.path();
let rel = format!(
"history/{}",
p.file_name().unwrap_or_default().to_string_lossy()
);
out.push((rel, p, true));
}
}
// Catalog cache (custom catalogs).
let cache = state.join("catalog-cache.json");
if cache.exists() {
out.push(("catalog-cache.json".to_string(), cache, true));
}
// Backups (recursive: one entry per file under backups/<id>/).
let backups = state.join("backups");
if let Ok(entries) = std::fs::read_dir(&backups) {
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let id = p.file_name().unwrap_or_default().to_string_lossy().to_string();
if let Ok(files) = std::fs::read_dir(&p) {
for f in files.flatten() {
let fp = f.path();
if fp.is_file() {
let rel = format!(
"backups/{}/{}",
id,
fp.file_name().unwrap_or_default().to_string_lossy()
);
out.push((rel, fp, false));
}
}
}
}
}
out
}
/// Write the bundle. Returns the number of bundled files.
pub fn export_bundle(app: &App, output: &Path) -> Result<usize> {
let files = collect_files(app);
let mut manifest = BundleManifest {
format: BUNDLE_FORMAT.to_string(),
version: BUNDLE_VERSION,
created: crate::installers::now_rfc3339(),
am_version: env!("CARGO_PKG_VERSION").to_string(),
files: Vec::new(),
};
let state_dir = app.events_dir();
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
let mut blobs: Vec<Vec<u8>> = Vec::new();
let mut offset: u64 = 0;
for (rel, abs, neutralize_paths) in &files {
let raw = std::fs::read(abs)
.with_context(|| format!("cannot read {}", abs.display()))?;
let data = if *neutralize_paths {
// Text files: neutralize absolute paths (works on both YAML and
// JSON since the placeholders are plain strings).
let text = String::from_utf8_lossy(&raw);
let neutral = neutralize(&text, &state_dir, &home);
neutral.into_bytes()
} else {
raw
};
manifest.files.push(BundleFile {
path: rel.clone(),
size: data.len() as u64,
sha256: sha256_hex(&data),
offset,
});
offset += data.len() as u64;
blobs.push(data);
}
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent)?;
}
let mut f = std::fs::File::create(output)
.with_context(|| format!("cannot create {}", output.display()))?;
let header = serde_json::to_string(&manifest)?;
writeln!(f, "{header}")?;
for b in &blobs {
f.write_all(b)?;
}
f.flush()?;
Ok(manifest.files.len())
}
/// Read and verify the bundle: parses the header, checks that every blob is
/// present and its hash matches. Returns (manifest, blob bytes).
pub fn read_bundle(path: &Path) -> Result<(BundleManifest, Vec<Vec<u8>>)> {
let mut raw = Vec::new();
std::fs::File::open(path)
.with_context(|| format!("cannot open {}", path.display()))?
.read_to_end(&mut raw)?;
let nl = raw
.iter()
.position(|&b| b == b'\n')
.ok_or_else(|| anyhow!("{}: missing header line", path.display()))?;
let header: BundleManifest = serde_json::from_slice(&raw[..nl])
.with_context(|| format!("{}: invalid bundle header", path.display()))?;
if header.format != BUNDLE_FORMAT {
anyhow::bail!("{}: not an am-migrate bundle", path.display());
}
let body = &raw[nl + 1..];
let mut blobs = Vec::new();
for f in &header.files {
let start = f.offset as usize;
let end = start + f.size as usize;
if end > body.len() {
anyhow::bail!(
"{}: truncated bundle — '{}' is incomplete (interrupted transfer?)",
path.display(),
f.path
);
}
let data = body[start..end].to_vec();
if sha256_hex(&data) != f.sha256 {
anyhow::bail!(
"{}: checksum mismatch for '{}' — bundle corrupted",
path.display(),
f.path
);
}
blobs.push(data);
}
Ok((header, blobs))
}
/// Restore a bundle into the state layout of machine B. The previous state
/// directory is moved aside (resumable, nothing lost). Returns the list of
/// restored files.
pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<String>> {
let (manifest, blobs) = read_bundle(bundle)?;
let state_dir = app.events_dir();
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
// Resumable import: park the current state aside, then write the new one.
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
if state_dir.exists() {
let park = state_dir.with_extension(format!("prev-{ts}"));
std::fs::rename(&state_dir, &park)
.with_context(|| format!("cannot park old state at {}", park.display()))?;
app.log
.info(&format!("previous state moved aside: {}", park.display()));
}
std::fs::create_dir_all(&state_dir)?;
let mut restored = Vec::new();
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
let rel = Path::new(&bf.path);
let target = if bf.path == "config.yaml" {
// Config goes to the ACTIVE user config directory of this app
// (respects --config / isolated tests) — NOT the machine-global
// user config dir: importing must not clobber the real config.
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
dir.join(crate::config::CONFIG_FILE_NAME)
} else {
let t = state_dir.join(rel);
if !t.starts_with(&state_dir) {
anyhow::bail!("security error: bundle contains unsafe path '{}'", bf.path);
}
t
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = data.clone();
if !rel.starts_with("backups") {
// Text content: restore machine B paths.
let text = String::from_utf8_lossy(&content);
content = deneutralize(&text, &state_dir, &home).into_bytes();
}
std::fs::write(&target, &content)
.with_context(|| format!("cannot write {}", target.display()))?;
restored.push(bf.path.clone());
}
// Config merge: the imported config replaces the local one only when
// confirmed; otherwise it is written next to it as config.migrated.yaml.
let cfg_rel = "config.yaml";
if restored.iter().any(|r| r == cfg_rel) && !confirm {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
if imported.exists() {
let target = dir.join("config.migrated.yaml");
std::fs::rename(&imported, &target)?;
app.log.info(&format!(
"imported config written as {} (use --yes to replace the local config)",
target.display()
));
}
}
restored.sort();
Ok(restored)
}
/// am migrate export/import.
pub fn run(app: &App, export: bool, output: Option<&Path>, bundle: Option<&Path>) -> Result<i32> {
if export {
let default_out = PathBuf::from("agent-manager-migrate.amx");
let out = output.unwrap_or(&default_out);
let n = export_bundle(app, out)?;
app.log.success(&format!(
"bundle written to {} ({n} files) — copy it to machine B and run 'am migrate import {}'",
out.display(),
out.display()
));
if let Some(dir) = app.paths.config_dir.as_ref() {
if dir.join(crate::config::CONFIG_FILE_NAME).exists() {
app.log.info("the bundle contains config.yaml, state.json, events, history, catalog cache and backups — absolute paths are neutralized");
}
}
return Ok(0);
}
let bundle = bundle.ok_or_else(|| anyhow!("usage: am migrate import <bundle.amx>"))?;
if !bundle.exists() {
anyhow::bail!("bundle not found: {}", bundle.display());
}
let ask = format!(
"import {} into this machine? The current state will be moved aside",
bundle.display()
);
let confirmed = app.confirm(&ask)?;
if !confirmed {
app.log.info("import cancelled");
return Ok(0);
}
let restored = import_bundle(app, bundle, app.cli.yes)?;
app.log.success(&format!("imported {} file(s)", restored.len()));
// Post-import verification (issue #68: doctor passes after import).
let problems = crate::commands::doctor_cmd::run(app, false, None)?;
if problems > 0 {
app.log.warn("doctor found issues after the import — review the report above");
} else {
app.log.success("doctor: environment OK after import");
}
app.log.info(
"next steps: (1) verify 'am list' and 'am status', (2) reinstall agents if needed ('am install <agent>'), (3) start a session",
);
Ok(0)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_is_stable() {
let h1 = sha256_hex(b"hello");
let h2 = sha256_hex(b"hello");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
assert_ne!(h1, sha256_hex(b"world"));
}
#[test]
fn neutralization_round_trip() {
let state = PathBuf::from("/mnt/machine-a/state");
let home = PathBuf::from("/home/user-a");
let text = format!(
"install_dir: {}\nrun: {}/bin/tool\n",
state.display(),
home.display()
);
let neutral = neutralize(&text, &state, &home);
assert!(neutral.contains("{{STATE_DIR}}"));
assert!(neutral.contains("{{HOME}}"));
assert!(!neutral.contains("machine-a"));
let state_b = PathBuf::from("/data/machine-b/state");
let home_b = PathBuf::from("/home/user-b");
let back = deneutralize(&neutral, &state_b, &home_b);
assert!(back.contains("/data/machine-b/state"));
assert!(back.contains("/home/user-b"));
}
fn test_app(tag: &str) -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
let mut app = crate::app::App::from_cli(cli).expect("app should build");
app.paths.state_file = dir.path().join(format!("state-{tag}/state.json"));
app.paths.config_dir = Some(dir.path().to_path_buf());
app
}
#[test]
fn export_import_round_trip() {
let app = test_app("exp");
let state = app.events_dir();
std::fs::create_dir_all(&state).unwrap();
std::fs::write(app.paths.state_file.clone(), "{\"version\":2,\"installed\":{}}").unwrap();
std::fs::write(state.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap();
std::fs::write(state.join("catalog-cache.json"), "{}").unwrap();
// The config file referenced by config_dir must exist to be bundled.
let cfg_path = app
.paths
.config_dir
.as_ref()
.unwrap()
.join(crate::config::CONFIG_FILE_NAME);
std::fs::write(&cfg_path, "version: \"1.0\"\nagents: []\n").unwrap();
let bundle = std::env::temp_dir().join(format!("am-mig-{}.amx", std::process::id()));
let n = export_bundle(&app, &bundle).unwrap();
assert!(n >= 3);
// Import into a second app with a different state dir.
let app_b = test_app("imp");
let restored = import_bundle(&app_b, &bundle, true).unwrap();
assert!(restored.iter().any(|r| r == "state.json"));
assert!(restored.iter().any(|r| r == "events-202608.jsonl"));
// State restored with the same content.
let state_b = app_b.events_dir();
let sf = state_b.join("state.json");
assert!(sf.exists());
let text = std::fs::read_to_string(sf).unwrap();
assert!(text.contains("\"version\":2"));
assert!(state_b.join("events-202608.jsonl").exists());
let _ = std::fs::remove_file(&bundle);
}
#[test]
fn truncated_bundle_is_detected() {
let app = test_app("trunc");
let state = app.events_dir();
std::fs::create_dir_all(&state).unwrap();
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
let bundle = std::env::temp_dir().join(format!("am-trunc-{}.amx", std::process::id()));
export_bundle(&app, &bundle).unwrap();
// Truncate the last byte.
let mut data = std::fs::read(&bundle).unwrap();
data.pop();
std::fs::write(&bundle, data).unwrap();
let err = read_bundle(&bundle).unwrap_err().to_string();
assert!(err.contains("truncated") || err.contains("checksum"), "{err}");
let _ = std::fs::remove_file(&bundle);
}
#[test]
fn tampered_blob_is_detected() {
let app = test_app("tamper");
let state = app.events_dir();
std::fs::create_dir_all(&state).unwrap();
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
let bundle = std::env::temp_dir().join(format!("am-tamp-{}.amx", std::process::id()));
export_bundle(&app, &bundle).unwrap();
// Flip a byte inside the first blob (after the header line), not in
// the header itself.
let mut data = std::fs::read(&bundle).unwrap();
let nl = data.iter().position(|&b| b == b'\n').unwrap();
let mid = nl + 1 + (data.len() - nl - 1) / 2;
data[mid] ^= 0xFF;
std::fs::write(&bundle, data).unwrap();
let err = read_bundle(&bundle).unwrap_err().to_string();
assert!(err.contains("checksum"), "{err}");
let _ = std::fs::remove_file(&bundle);
}
}
+188 -10
View File
@@ -1,8 +1,11 @@
//! Command implementations and dispatch.
pub mod agents_cmd;
pub mod ai_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
pub mod catalog_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod dashboard_cmd;
@@ -13,30 +16,46 @@ pub mod history_cmd;
pub mod info_cmd;
pub mod init_cmd;
pub mod install_cmd;
pub mod lab_cmd;
pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod man_cmd;
pub mod models_cmd;
pub mod monitor_cmd;
pub mod migrate_cmd;
pub mod open_cmd;
pub mod profile_cmd;
pub mod projects_cmd;
pub mod registry_cmd;
pub mod run_cmd;
pub mod schedule_cmd;
pub mod search_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod self_update;
pub mod serve_cmd;
pub mod service_cmd;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod suggest_cmd;
pub mod sync_cmd;
pub mod playbook_cmd;
pub mod plugins_cmd;
pub mod providers_cmd;
pub mod theme_cmd;
pub mod timeline_cmd;
pub mod tip_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod watch_cmd;
pub mod version_cmd;
pub mod watch_cmd;
pub mod web_cmd;
use crate::app::App;
use crate::cli::Command;
use crate::config::{AgentDef, MethodSpec};
use crate::config::{AgentDef, MethodSpec, UpdatePolicy};
use crate::state::InstalledEntry;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
@@ -82,6 +101,20 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Agents { installed } => agents_cmd::run(app, *installed),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Models(args) => models_cmd::run(app, args),
Command::Registry(args) => registry_cmd::run(app, args.sub.as_ref()),
Command::Catalog(sub) => catalog_cmd::run(app, sub),
Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()),
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
Command::Ai(args) => ai_cmd::run(app, args),
Command::Setup { roles } => {
if *roles {
crate::setup::run_roles(app)
} else {
crate::setup::run(app)
}
}
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -94,7 +127,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
timeout,
} => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
Command::Stats { agent, period, costs } => {
stats_cmd::run(app, agent.as_deref(), period.as_deref(), *costs)
}
Command::Monitor { interval, json } => {
monitor_cmd::run(app, *interval, *json)
}
Command::Web(args) => web_cmd::run(app, args.port, args.no_open),
Command::Serve(args) => serve_cmd::run(app, args),
Command::Sync { message } => sync_cmd::run(app, message.as_deref()),
Command::Migrate { export, output, bundle } => {
migrate_cmd::run(app, *export, output.as_deref(), bundle.as_deref())
}
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report {
last_week,
@@ -121,6 +165,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status,
show,
resume,
export,
output,
retention,
} => sessions_cmd::run(
app,
agent.as_deref(),
@@ -128,14 +175,31 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status.as_deref(),
show.as_deref(),
resume.as_deref(),
export.as_deref(),
output.as_deref(),
*retention,
),
Command::Install {
agent,
method,
force,
} => install_cmd::run(app, agent, method.as_deref(), *force),
provider,
model,
no_config,
} => install_cmd::run(
app,
&agent,
method.as_deref(),
*force,
provider.as_deref(),
model.as_deref(),
*no_config,
),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Update { agent, all, rollback } => {
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
}
Command::Audit => audit_cmd::run(app),
Command::Search { keyword, category, tag } => {
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
}
@@ -147,6 +211,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
session,
limit,
rerun,
range,
save,
} => history_cmd::run(
app,
kind.as_deref(),
@@ -156,14 +222,67 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
session.as_deref(),
*limit,
*rerun,
range.as_deref(),
save.as_deref(),
),
Command::Playbook { path, var } => playbook_cmd::run(app, path, var),
Command::Lab(args) => lab_cmd::run(app, args),
Command::Plugins(args) => plugins_cmd::run(app, args),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force } => init_cmd::run(app, *force),
Command::Init { force, template } => {
init_cmd::run(app, *force, template.as_deref())
}
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app),
Command::Tip { random } => tip_cmd::run(app, *random),
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::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix, watch } => {
doctor_cmd::run(app, *fix, *watch)
}
Command::Run { agent, model, provider, container, no_sandbox, args } => {
run_cmd::run(
app,
agent,
model.as_deref(),
provider.as_deref(),
*container,
*no_sandbox,
args,
)
}
Command::Service(svc) => match svc {
crate::cli::ServiceCmd::Install { agent, autostart } => {
service_cmd::run(app, "install", Some(agent), *autostart)
}
crate::cli::ServiceCmd::Uninstall { agent } => {
service_cmd::run(app, "uninstall", Some(agent), false)
}
crate::cli::ServiceCmd::Status { agent } => {
service_cmd::run(app, "status", Some(agent), false)
}
},
Command::Schedule(sched) => match sched {
crate::cli::ScheduleCmd::Add { command, at } => {
// '--at HH:MM' may land inside the trailing command args
// (allow_hyphen_values): strip it and recover the value.
let mut cmd = command.clone();
let mut at_value = at.clone();
if let Some(idx) = cmd.iter().position(|a| a == "--at") {
if idx + 1 < cmd.len() {
at_value = at_value.or_else(|| Some(cmd[idx + 1].clone()));
cmd.remove(idx + 1);
}
cmd.remove(idx);
}
schedule_cmd::run(app, "add", cmd, at_value.as_deref())
}
crate::cli::ScheduleCmd::List => schedule_cmd::run(app, "list", vec![], None),
crate::cli::ScheduleCmd::Remove { id } => {
schedule_cmd::run(app, "remove", vec![id.clone()], None)
}
crate::cli::ScheduleCmd::Run { id } => schedule_cmd::run(app, "run", vec![id.clone()], None),
},
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
@@ -200,6 +319,12 @@ pub fn resolve_exec(
Some(e) if !e.bins.is_empty() => {
let p = std::path::PathBuf::from(&e.bins[0]);
if p.exists() || app.dry_run() {
// The recorded bin replaces the run command's first token
// (e.g. "picoclaw"): consume it so it is not appended to
// the argument list. Regression: am start <agent géré>
// lançait "<bin> <token-run>" — picoclaw rejetait son propre
// nom comme sous-commande inconnue.
tokens.remove(0);
p.display().to_string()
} else {
tokens.remove(0)
@@ -220,9 +345,18 @@ pub fn resolve_exec(
args.extend(agent.args.iter().cloned());
args.extend(extra_args.iter().cloned());
let mut env = agent.env.clone();
// Issue #91: the `config.env_map` block, resolved against the agent's
// provider — api_key stays the @secret reference (resolved below).
crate::agent_config::apply_env_map(&mut env, agent, &app.config);
env.extend(extra_env.clone());
let warnings =
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
crate::secrets::resolve_env_secrets(
&crate::secrets::store(),
&agent.name,
crate::agent_config::provider_pref(agent)
.or_else(|| crate::providers::default_name(&app.config)),
&mut env,
);
for w in warnings {
app.log.warn(&w);
}
@@ -345,6 +479,7 @@ pub fn select_method<'a>(
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn routes_cmd_scripts_through_comspec() {
@@ -370,4 +505,47 @@ mod tests {
assert_eq!(program, "node");
assert_eq!(args, vec!["-v".to_string()]);
}
#[test]
fn resolve_exec_installed_agent_does_not_leak_run_token() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents:\n - name: x\n run: x\n installable: false\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
app.paths.state_file = dir.join("state.json");
app.state = crate::state::StateStore::new(app.paths.state_file.clone());
// A recorded installed bin that exists: the run token "x" must be
// consumed, not appended to the argument list.
let bin = dir.join("x.exe");
std::fs::write(&bin, b"x").unwrap();
app.state
.set(&crate::state::InstalledEntry {
name: "x".to_string(),
version: Some("1.0".to_string()),
method: "binary".to_string(),
install_dir: dir.display().to_string(),
bins: vec![bin.display().to_string()],
run: "x".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
})
.unwrap();
let agent = app.catalog.resolve("x").unwrap();
let exec = resolve_exec(&app, agent, &[], &BTreeMap::new()).unwrap();
assert_eq!(exec.program, bin.display().to_string());
assert!(
exec.args.is_empty(),
"run token leaked into args: {:?}",
exec.args
);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio)
//! and pruning of unused models (issues #70 #73, axe 11).
use super::*;
use crate::cli::ModelsArgs;
use crate::events::{Event, EventKind};
use crate::models::{self, RuntimeModel};
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize)]
struct ModelRow {
runtime: String,
name: String,
size_bytes: Option<u64>,
quantization: Option<String>,
last_used: Option<String>,
}
#[derive(Serialize)]
struct RuntimeRow {
runtime: String,
detected: bool,
note: Option<String>,
models: usize,
}
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
if args.prune {
return prune(app, args);
}
let inventory = models::detect(app);
if app.json() {
print_json(&serde_json::json!({
"runtimes": inventory.runtimes,
"models": inventory.models,
}));
return Ok(0);
}
let theme = app.theme();
let color = app.color();
// Runtime status lines.
for r in &inventory.runtimes {
let icon = if r.detected { "✓" } else { "✗" };
let note = r.note.as_deref().unwrap_or("");
println!(" {icon} {} — {note}", r.runtime);
}
if !inventory.any_runtime() {
app.log.info(
"no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API",
);
return Ok(0);
}
if inventory.models.is_empty() {
app.log.info("runtimes detected but no models found");
return Ok(0);
}
// Table: runtime | model | size | quant | last used.
let rows: Vec<Vec<String>> = inventory
.models
.iter()
.map(|m| {
vec![
m.runtime.clone(),
m.name.clone(),
human_size(m.size_bytes),
m.quantization.clone().unwrap_or_else(|| "—".to_string()),
m.last_used.clone().unwrap_or_else(|| "—".to_string()),
]
})
.collect();
let _ = (theme, color);
print!(
"{}",
crate::output::boxed_table(
vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"],
rows,
app.theme(),
app.color(),
)
);
println!(
"\n {} model(s) — 'am models --prune --dry-run' lists unused ones",
inventory.models.len()
);
Ok(0)
}
/// prune: list unused models, then delete them after confirmation.
fn prune(app: &App, args: &ModelsArgs) -> Result<i32> {
let days = args.days.unwrap_or_else(|| {
app.config
.settings
.models_prune_days
.unwrap_or(models::DEFAULT_PRUNE_DAYS)
});
let inventory = models::detect(app);
let candidates = models::prune_candidates(&inventory.models, days);
let _ = &inventory;
if app.json() {
let rows: Vec<ModelRow> = candidates
.iter()
.map(|m| ModelRow {
runtime: m.runtime.clone(),
name: m.name.clone(),
size_bytes: m.size_bytes,
quantization: m.quantization.clone(),
last_used: m.last_used.clone(),
})
.collect();
print_json(&rows);
return Ok(if rows.is_empty() { 0 } else { 1 });
}
if candidates.is_empty() {
app.log.success(&format!(
"no unused model found (threshold: {days} days; ':latest' models are always kept)"
));
return Ok(0);
}
println!(
"{} model(s) unused for more than {days} days (':latest' models are protected):",
candidates.len()
);
for m in &candidates {
let size = human_size(m.size_bytes);
let last = m.last_used.as_deref().unwrap_or("unknown");
println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name);
}
if args.dry_run {
app.log.info("dry-run: nothing was deleted — remove --dry-run to prune");
return Ok(0);
}
let asked = app
.confirm(&format!("delete these {} model(s)?", candidates.len()))?;
if !asked {
app.log.info("prune cancelled");
return Ok(0);
}
let mut removed = 0;
let mut errors = 0;
for m in &candidates {
match models::remove_model(m) {
Ok(()) => {
app.emit(
&Event::now(EventKind::Model)
.with_agent(m.runtime.clone())
.with_args(vec![m.name.clone()])
.with_reason("prune"),
);
app.log.success(&format!("removed {} ({})", m.name, m.runtime));
removed += 1;
}
Err(e) => {
app.log.error(&format!("{}: {e:#}", m.name));
errors += 1;
}
}
}
// The inventory cache is stale after a prune: drop it so the next run
// rescans the runtimes.
let cache_path = app.events_dir().join("models-cache.json");
let _ = std::fs::remove_file(&cache_path);
if errors > 0 {
app.log
.error(&format!("{errors} model(s) could not be removed"));
return Ok(1);
}
app.log
.success(&format!("{removed} model(s) removed from the local runtimes"));
Ok(0)
}
/// Human-readable size ("1.2 GB", "450 MB", "—" when unknown).
fn human_size(bytes: Option<u64>) -> String {
match bytes {
None => "—".to_string(),
Some(b) => {
let kb = b as f64 / 1024.0;
let mb = kb / 1024.0;
let gb = mb / 1024.0;
if gb >= 1.0 {
format!("{gb:.1} GB")
} else if mb >= 1.0 {
format!("{mb:.0} MB")
} else {
format!("{kb:.0} KB")
}
}
}
}
/// Usage of each model name across sessions (for --json consumers).
#[allow(dead_code)]
fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap<String, u64> {
BTreeMap::new()
}
+405
View File
@@ -0,0 +1,405 @@
//! monitor: real-time TUI of the managed processes (issue #50). Lists every
//! running agent with CPU %, RSS, uptime; alerts on configurable thresholds
//! (settings.monitor_thresholds) with a desktop notification + journal event.
//! `--json` prints one JSON document per tick for scripts.
use super::*;
use crate::config::MonitorThresholds;
use crate::events::{Event, EventKind};
use anyhow::{Context, Result};
use serde::Serialize;
use std::process::Command;
use std::time::Duration;
/// One measured process row.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AgentMetric {
pub agent: String,
pub pid: u32,
pub cpu_pct: f64,
pub mem_mb: f64,
pub uptime_s: u64,
/// "running" when the process answers, "dead" otherwise.
pub state: String,
}
/// Raw reading of a process (platform-dependent).
#[derive(Debug, Clone, Copy, Default)]
pub struct RawReading {
/// Total CPU time consumed so far (seconds).
pub cpu_total_s: f64,
/// Resident set size in bytes.
pub rss_bytes: u64,
}
/// Read the raw counters of a process. Windows: PowerShell Get-Process
/// (CPU = total seconds, WorkingSet64 = RSS). Unix: ps.
fn raw_reading(pid: u32) -> Option<RawReading> {
#[cfg(windows)]
{
let out = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"Get-Process -Id {pid} | Select-Object -ExpandProperty CPU; Get-Process -Id {pid} | Select-Object -ExpandProperty WorkingSet64"
),
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
let cpu: f64 = lines.next()?.trim().parse().ok()?;
let rss: u64 = lines.next()?.trim().parse().ok()?;
Some(RawReading {
cpu_total_s: cpu,
rss_bytes: rss,
})
}
#[cfg(unix)]
{
let out = Command::new("ps")
.args(["-o", "time=,rss=", "-p", &pid.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut parts = text.split_whitespace();
let time = parts.next()?;
let rss_kb: u64 = parts.next()?.parse().ok()?;
Some(RawReading {
cpu_total_s: parse_ps_time(time),
rss_bytes: rss_kb * 1024,
})
}
}
/// Parse `ps` cumulative time "MM:SS" or "HH:MM:SS" into seconds.
fn parse_ps_time(t: &str) -> f64 {
let parts: Vec<f64> = t
.split(':')
.filter_map(|p| p.parse().ok())
.collect();
match parts.as_slice() {
[m, s] => m * 60.0 + s,
[h, m, s] => h * 3600.0 + m * 60.0 + s,
_ => 0.0,
}
}
/// Turn raw readings (taken `interval_s` apart) into a metric row.
/// Pure: unit tested.
pub fn to_metric(
agent: &str,
pid: u32,
before: Option<RawReading>,
after: RawReading,
started_at: Option<&str>,
interval_s: f64,
) -> AgentMetric {
let cpu_pct = match before {
Some(b) if interval_s > 0.0 => {
let delta = (after.cpu_total_s - b.cpu_total_s).max(0.0);
(delta / interval_s * 100.0).min(100.0 * 64.0) // multi-core cap, generous
}
_ => 0.0,
};
let uptime_s = started_at
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64)
.unwrap_or(0);
AgentMetric {
agent: agent.to_string(),
pid,
cpu_pct,
mem_mb: after.rss_bytes as f64 / (1024.0 * 1024.0),
uptime_s,
state: "running".to_string(),
}
}
/// Decide which alerts fire for a metric against the thresholds. Pure.
pub fn alerts_for(m: &AgentMetric, t: &MonitorThresholds) -> Vec<String> {
let mut out = Vec::new();
if let Some(cpu) = t.cpu_pct {
if cpu > 0.0 && m.cpu_pct > cpu {
out.push(format!(
"{} cpu {:.1}% > {cpu}%",
m.agent, m.cpu_pct
));
}
}
if let Some(mem) = t.mem_mb {
if mem > 0 && m.mem_mb > mem as f64 {
out.push(format!(
"{} rss {:.0} MB > {mem} MB",
m.agent, m.mem_mb
));
}
}
out
}
/// Collect the metrics of every managed agent with a live PID.
pub fn collect(app: &App, interval_s: f64) -> Vec<AgentMetric> {
let mut out = Vec::new();
let Ok(sf) = app.state.load() else {
return out;
};
for (name, entry) in &sf.installed {
let Some(pid) = entry.pid else { continue };
let Some(after) = raw_reading(pid) else {
out.push(AgentMetric {
agent: name.clone(),
pid,
state: "dead".to_string(),
..Default::default()
});
continue;
};
let before = if interval_s > 0.0 {
std::thread::sleep(Duration::from_secs_f64(interval_s));
raw_reading(pid)
} else {
None
};
out.push(to_metric(
name,
pid,
before,
after,
entry.started_at.as_deref(),
interval_s,
));
}
out
}
/// Emit one JSON document (one row per process) — script-friendly.
fn print_json_rows(rows: &[AgentMetric]) {
crate::output::print_json(&rows);
}
/// Run the monitor: TUI by default, --json for scripts.
pub fn run(app: &App, interval: Option<u64>, json: bool) -> Result<i32> {
let secs = interval.unwrap_or(2).max(1) as f64;
let thresholds = app
.config
.settings
.monitor_thresholds
.clone()
.unwrap_or_default();
if json {
// One JSON document per tick, then sleep; Ctrl-C stops.
loop {
let rows = collect(app, 0.0);
print_json_rows(&rows);
for m in &rows {
fire_alerts(app, m, &thresholds);
}
std::thread::sleep(Duration::from_secs_f64(secs));
}
}
tui_loop(app, secs, &thresholds)
}
/// Fire a desktop notification + journal event when a threshold is crossed.
/// Alerts are rate-limited by the caller (one per tick).
fn fire_alerts(app: &App, m: &AgentMetric, t: &MonitorThresholds) {
for alert in alerts_for(m, t) {
let _ = crate::process::desktop_notify("agent-manager monitor", &alert);
app.emit(
&Event::now(EventKind::Alert)
.with_agent(m.agent.clone())
.with_pid(m.pid)
.with_reason(format!("monitor: {alert}")),
);
}
}
/// ratatui loop: list of managed processes, refresh every `secs`, q/Esc quits.
fn tui_loop(app: &App, secs: f64, thresholds: &MonitorThresholds) -> Result<i32> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::DefaultTerminal;
let mut terminal = ratatui::init();
let result = (|| -> Result<i32> {
loop {
let rows = collect(app, secs);
for m in &rows {
fire_alerts(app, m, thresholds);
}
terminal.draw(|f| {
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
let title = Line::from(vec![
Span::styled(
" am monitor ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} managed process(es)", rows.len()),
Style::default().fg(Color::Gray),
),
]);
f.render_widget(
Block::default().borders(Borders::TOP).title(title),
chunks[0],
);
let items: Vec<ListItem> = rows
.iter()
.map(|m| {
let state = if m.state == "dead" {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
};
ListItem::new(Line::from(vec![
Span::styled(
format!(
"{:<20} pid {:>7} cpu {:>6.1}% rss {:>8.0} MB up {}",
m.agent,
m.pid,
m.cpu_pct,
m.mem_mb,
human_uptime(m.uptime_s),
),
state,
),
]))
})
.collect();
f.render_widget(
List::new(items).block(Block::default().borders(Borders::ALL)),
chunks[1],
);
let hint = Line::from(Span::styled(
" q / Esc: quit ",
Style::default().fg(Color::DarkGray),
));
f.render_widget(hint, chunks[2]);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press
&& matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
{
return Ok(0);
}
}
}
}
})();
ratatui::restore();
result
}
fn human_uptime(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_metric_computes_cpu_and_mem() {
let before = RawReading {
cpu_total_s: 10.0,
rss_bytes: 2 * 1024 * 1024,
};
let after = RawReading {
cpu_total_s: 12.0,
rss_bytes: 4 * 1024 * 1024,
};
let m = to_metric("a", 42, Some(before), after, None, 2.0);
assert_eq!(m.pid, 42);
assert!((m.cpu_pct - 100.0).abs() < 1e-6, "{}", m.cpu_pct);
assert!((m.mem_mb - 4.0).abs() < 1e-6);
assert_eq!(m.state, "running");
}
#[test]
fn to_metric_first_sample_zero_cpu() {
let after = RawReading {
cpu_total_s: 5.0,
rss_bytes: 1024 * 1024,
};
let m = to_metric("a", 1, None, after, None, 2.0);
assert_eq!(m.cpu_pct, 0.0);
}
#[test]
fn to_metric_parses_uptime() {
let now = chrono::Utc::now();
let started = now - chrono::Duration::minutes(5);
let after = RawReading {
cpu_total_s: 1.0,
rss_bytes: 1024 * 1024,
};
let m = to_metric("a", 1, None, after, Some(&started.to_rfc3339()), 0.0);
assert!((260..=320).contains(&m.uptime_s), "{}", m.uptime_s);
}
#[test]
fn alerts_fire_on_thresholds() {
let m = AgentMetric {
agent: "a".into(),
pid: 1,
cpu_pct: 95.0,
mem_mb: 2000.0,
uptime_s: 0,
state: "running".into(),
};
let t = MonitorThresholds {
cpu_pct: Some(80.0),
mem_mb: Some(1024),
};
let alerts = alerts_for(&m, &t);
assert_eq!(alerts.len(), 2);
// Below thresholds → nothing.
let quiet = AgentMetric {
cpu_pct: 10.0,
mem_mb: 100.0,
..m.clone()
};
assert!(alerts_for(&quiet, &t).is_empty());
// 0 disables the threshold.
let off = MonitorThresholds {
cpu_pct: Some(0.0),
mem_mb: Some(0),
};
assert!(alerts_for(&m, &off).is_empty());
}
#[test]
fn parse_ps_time_formats() {
assert!((parse_ps_time("01:30") - 90.0).abs() < 1e-9);
assert!((parse_ps_time("1:02:03") - 3723.0).abs() < 1e-9);
}
}
+20
View File
@@ -0,0 +1,20 @@
//! playbook: replay a saved playbook step by step (issue #51).
//! Playbooks are created with 'am history <from>..<to> --save <file>'.
use super::*;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
pub fn run(app: &App, path: &Path, vars: &[String]) -> Result<i32> {
let mut values = BTreeMap::new();
for v in vars {
let Some((k, val)) = v.split_once('=') else {
anyhow::bail!("--var expects name=value, got '{v}'");
};
values.insert(k.trim().to_string(), val.trim().to_string());
}
let resolved = crate::playbook::resolve_dest(app, path);
let used = if resolved.exists() { resolved } else { path.to_path_buf() };
crate::playbook::run_playbook(app, &used, &values)
}
+102
View File
@@ -0,0 +1,102 @@
//! plugins: list the event plugins and test one against a synthetic event
//! (issue #75). 'am plugins --test <name>' is the CI hook: it exercises the
//! JSON contract end to end and exits non-zero on failure.
use super::*;
use crate::cli::PluginsArgs;
use crate::plugins::{self, PluginStatus};
use anyhow::Result;
pub fn run(app: &App, args: &PluginsArgs) -> Result<i32> {
let dir = plugins::dir(app);
let all = plugins::discover(app, &dir);
if let Some(name) = &args.test {
let Some(plugin) = all.iter().find(|p| &p.name == name) else {
anyhow::bail!(
"unknown plugin '{name}' — 'am plugins' lists the plugins of {}",
dir.display()
);
};
// Synthetic event (a start, agent 'test') to exercise the contract.
let event = crate::events::Event::now(crate::events::EventKind::Start)
.with_agent("test")
.with_args(vec!["--probe".to_string()]);
let r = plugins::run_plugin(app, plugin, "on_start", &event);
if app.json() {
crate::output::print_json(&serde_json::json!({
"name": plugin.name,
"status": r.status.as_str(),
"exit_code": r.exit_code,
"duration_ms": r.duration_ms,
"response": r.response,
"stdout": r.stdout,
"stderr": r.stderr,
"error": r.error,
}));
} else {
app.log.info(&format!(
"plugin '{}' — {} in {}ms",
plugin.name,
r.status.as_str(),
r.duration_ms
));
if let Some(code) = r.exit_code {
app.log.info(&format!("exit code: {code}"));
}
if let Some(resp) = &r.response {
if let Some(msg) = &resp.message {
app.log.info(&format!("message: {msg}"));
}
if let Some(err) = &resp.error {
app.log.warn(&format!("error: {err}"));
}
}
if !r.stdout.trim().is_empty() {
app.log.info(&format!("stdout: {}", r.stdout.trim()));
}
if !r.stderr.trim().is_empty() {
app.log.warn(&format!("stderr: {}", r.stderr.trim()));
}
if let Some(e) = &r.error {
app.log.warn(&format!("{e}"));
}
}
return Ok(if r.status == PluginStatus::Ok { 0 } else { 1 });
}
if app.json() {
let items: Vec<serde_json::Value> = all
.iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"events": p.events,
"timeout_secs": p.timeout.as_secs(),
"script": p.file.display().to_string(),
})
})
.collect();
crate::output::print_json(&items);
return Ok(0);
}
if all.is_empty() {
app.log.info(&format!(
"no plugins in {} — copy one from examples/plugins/ (notify, ci-webhook)",
dir.display()
));
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["PLUGIN", "EVENTS", "TIMEOUT", "SCRIPT"]);
for p in &all {
table.row(vec![
p.name.clone(),
p.events.join(", "),
format!("{}s", p.timeout.as_secs()),
p.file.display().to_string(),
]);
}
print!("{}", table.render());
Ok(0)
}
+492
View File
@@ -0,0 +1,492 @@
//! providers: the LLM provider registry (issue #88). `settings.providers`
//! maps a provider name to its base URL, model list and default model; the
//! default provider is `settings.default_provider`. Tokens are NOT handled
//! here — they live in the OS keyring (issue #89).
use super::*;
use crate::cli::ProvidersCmd;
use crate::config::ProviderDef;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use crate::providers;
use anyhow::{anyhow, bail, Result};
use crate::secrets::SecretStore;
use std::path::Path;
pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
match sub {
None => list(app),
Some(ProvidersCmd::List) => list(app),
Some(ProvidersCmd::Show { name }) => show(app, name),
Some(ProvidersCmd::Add {
name,
base_url,
model,
models,
}) => add(app, name, base_url, model.as_deref(), models.as_deref()),
Some(ProvidersCmd::Remove { name }) => remove(app, name),
Some(ProvidersCmd::SetToken { name, value }) => set_token(app, name, value),
Some(ProvidersCmd::Token { name }) => check_token(app, name),
Some(ProvidersCmd::Default { name }) => set_default(app, name),
}
}
// ---------------------------------------------------------------------------
// list / show
// ---------------------------------------------------------------------------
fn list(app: &App) -> Result<i32> {
let providers = providers::all(&app.config);
let default_name = providers::default_name(&app.config);
let defined: Vec<(&String, &ProviderDef)> = providers
.iter()
.filter_map(|(k, v)| v.as_ref().map(|def| (k, def)))
.collect();
if app.json() {
let rows: Vec<serde_json::Value> = defined
.iter()
.map(|(name, def)| provider_json(name, def, default_name == Some(name.as_str())))
.collect();
print_json(&rows);
return Ok(0);
}
if defined.is_empty() {
app.log.info(&crate::i18n::tr(
"aucun provider enregistré — voir: am providers add <nom> --base-url <url>",
));
return Ok(0);
}
let mut table = crate::output::Table::new(vec![
"PROVIDER",
"BASE URL",
"DÉFAUT",
"MODÈLES",
]);
for (name, def) in &defined {
let star = if default_name == Some(name.as_str()) {
"★"
} else {
""
};
table.row(vec![
format!("{name}{star}"),
def.base_url.clone(),
def.default_model.clone().unwrap_or_default(),
def.models.join(", "),
]);
}
print!("{}", table.render());
Ok(0)
}
fn show(app: &App, name: &str) -> Result<i32> {
let def = providers::get(&app.config, name)
.ok_or_else(|| anyhow!(crate::tr_fmt!("provider '{}' inconnu", name)))?;
let is_default = providers::default_name(&app.config) == Some(name);
if app.json() {
print_json(&provider_json(name, def, is_default));
return Ok(0);
}
println!("provider: {name}");
if is_default {
println!("(provider par défaut)");
}
println!(" base_url: {}", def.base_url);
println!(
" modèle par défaut: {}",
def.default_model.as_deref().unwrap_or("-")
);
println!(" modèles: {}", def.models.join(", "));
Ok(0)
}
/// One provider as a JSON object. Tokens are never part of this shape.
fn provider_json(name: &str, def: &ProviderDef, is_default: bool) -> serde_json::Value {
serde_json::json!({
"name": name,
"base_url": def.base_url,
"default_model": def.default_model,
"models": def.models,
"is_default": is_default,
})
}
// ---------------------------------------------------------------------------
// add / remove / default / set-token / token
// ---------------------------------------------------------------------------
/// `am providers set-token <nom> --value <v>` — store the provider API token
/// in the OS keyring under providers/<nom>/api_key (issue #89). The value
/// never leaves the keyring; every agent of this provider resolves it
/// through the @secret cascade at start/run.
fn set_token(app: &App, name: &str, value: &str) -> Result<i32> {
if providers::get(&app.config, name).is_none() {
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
}
if app.dry_run() {
app.log.dry(&format!("would store the token of provider {name} in the OS keyring"));
return Ok(0);
}
let key = crate::secrets::key_for_provider_token(name);
crate::secrets::store().set(&key, value)?;
app.emit(&Event::now(EventKind::Provider).with_args(vec![
"action=set-token".to_string(),
format!("provider={name}"),
]));
app.log.success(&crate::tr_fmt!("token stocké pour le provider '{}'", name));
app.log.info("shared by every agent of this provider via --env NAME=@secret");
Ok(0)
}
/// `am providers token <nom>` — check that a token exists; the value is
/// never displayed. --json emits {"provider": ..., "token_present": bool}.
fn check_token(app: &App, name: &str) -> Result<i32> {
if providers::get(&app.config, name).is_none() {
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
}
let key = crate::secrets::key_for_provider_token(name);
let present = crate::secrets::store().get(&key).ok().flatten().is_some();
if app.json() {
print_json(&serde_json::json!({
"provider": name,
"token_present": present,
}));
return Ok(0);
}
if present {
app.log.success(&crate::tr_fmt!("token présent pour le provider '{}'", name));
} else {
app.log.info(&crate::tr_fmt!(
"aucun token pour le provider '{}' — voir: am providers set-token {}",
name,
name
));
}
Ok(0)
}
fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result<i32> {
if !providers::is_valid_name(name) {
bail!(
"{}",
crate::tr_fmt!(
"nom de provider invalide '{}' — attendu un slug [a-z0-9_-]",
name
)
);
}
if base_url.trim().is_empty() {
bail!("{}", crate::i18n::tr("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]"));
}
let models_list: Vec<String> = models
.map(|m| m.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
.unwrap_or_default();
if app.dry_run() {
app.log.dry(&format!(
"would register provider {name} (base_url={base_url}, default_model={})",
model.unwrap_or("-")
));
return Ok(0);
}
let existed = providers::get(&app.config, name).is_some();
let def = ProviderDef {
base_url: base_url.trim().to_string(),
default_model: model.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()),
models: models_list,
};
write_provider(app, name, &def)?;
app.emit(
&Event::now(EventKind::Provider)
.with_args(vec![format!("action={}", if existed { "update" } else { "add" }), format!("provider={name}")]),
);
if existed {
app.log.success(&crate::tr_fmt!("provider '{}' mis à jour", name));
} else {
app.log.success(&crate::tr_fmt!("provider '{}' ajouté", name));
app.log.info(&format!(
"{}",
crate::tr_fmt!(
"positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)",
name
)
));
}
Ok(0)
}
fn remove(app: &App, name: &str) -> Result<i32> {
if providers::get(&app.config, name).is_none() {
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
}
if providers::default_name(&app.config) == Some(name) {
bail!(crate::tr_fmt!(
"le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut",
name
));
}
if app.dry_run() {
app.log.dry(&format!("would remove provider {name}"));
return Ok(0);
}
let path = crate::commands::config_cmd::active_config_path(app)?;
let mut root = read_yaml(&path)?;
// Write an explicit `null` instead of removing the key: the effective
// config is a merge (default catalog + user overlay) and a null marker
// is what actually deletes a provider shipped in the defaults (issue #90).
set_path(
&mut root,
&["settings", "providers", name],
serde_yaml::Value::Null,
)?;
write_yaml(&path, &root)?;
crate::commands::config_cmd::reload_and_validate(&path)?;
app.emit(
&Event::now(EventKind::Provider)
.with_args(vec!["action=remove".to_string(), format!("provider={name}")]),
);
app.log.success(&crate::tr_fmt!("provider '{}' supprimé", name));
Ok(0)
}
fn set_default(app: &App, name: &str) -> Result<i32> {
if providers::get(&app.config, name).is_none() {
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
}
if app.dry_run() {
app.log.dry(&format!("would set the default provider to {name}"));
return Ok(0);
}
crate::commands::config_cmd::persist_setting(app, "settings.default_provider", name)?;
app.emit(
&Event::now(EventKind::Provider)
.with_args(vec!["action=default".to_string(), format!("provider={name}")]),
);
app.log.success(&crate::tr_fmt!("provider par défaut : {}", name));
Ok(0)
}
// ---------------------------------------------------------------------------
// Config file edition (settings.providers.<name>)
// ---------------------------------------------------------------------------
/// Write (or update) one provider entry in the active configuration file.
fn write_provider(app: &App, name: &str, def: &ProviderDef) -> Result<()> {
let path = crate::commands::config_cmd::active_config_path(app)?;
let mut root = read_yaml(&path)?;
let base = [
"settings",
"providers",
name,
"base_url",
];
set_path(&mut root, &base, serde_yaml::Value::String(def.base_url.clone()))?;
if let Some(m) = &def.default_model {
set_path(
&mut root,
&["settings", "providers", name, "default_model"],
serde_yaml::Value::String(m.clone()),
)?;
}
if !def.models.is_empty() {
let seq = serde_yaml::Value::Sequence(
def.models.iter().map(|m| serde_yaml::Value::String(m.clone())).collect(),
);
set_path(&mut root, &["settings", "providers", name, "models"], seq)?;
}
write_yaml(&path, &root)?;
crate::commands::config_cmd::reload_and_validate(&path)
}
fn read_yaml(path: &Path) -> Result<serde_yaml::Value> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?;
serde_yaml::from_str(&text).map_err(|e| anyhow!("{} is not valid YAML: {e}", path.display()))
}
fn write_yaml(path: &Path, root: &serde_yaml::Value) -> Result<()> {
std::fs::write(path, serde_yaml::to_string(root)?)
.map_err(|e| anyhow!("cannot write {}: {e}", path.display()))
}
/// Set a value at a dotted path, creating intermediate mappings. A null
/// value already present on the path (a provider deletion marker from
/// `providers remove`, issue #90) is replaced by a fresh mapping.
fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Value) -> Result<()> {
let mut node = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
for part in &parts[..parts.len() - 1] {
let entry = node
.entry(serde_yaml::Value::String(part.to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
if entry.is_null() {
*entry = serde_yaml::Value::Mapping(Default::default());
}
node = entry
.as_mapping_mut()
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
}
node.insert(
serde_yaml::Value::String(parts.last().unwrap().to_string()),
value,
);
Ok(())
}
/// Remove the key at a dotted path (missing keys are a no-op).
fn remove_path(root: &mut serde_yaml::Value, parts: &[&str]) -> Result<()> {
let mut node = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
for part in &parts[..parts.len() - 1] {
let Some(entry) = node.get_mut(serde_yaml::Value::String(part.to_string())) else {
return Ok(());
};
node = entry
.as_mapping_mut()
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
}
node.remove(serde_yaml::Value::String(parts.last().unwrap().to_string()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use clap::Parser;
use std::path::PathBuf;
/// App whose active config is a file inside a leaked TempDir (same
/// pattern as config_cmd's tests).
fn app_with_config(text: &str) -> (App, PathBuf) {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, text).unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
(app, cfg)
}
const BASE: &str = "version: \"1.0\"\nagents: []\n";
#[test]
fn set_path_descends_and_writes_sequences() {
let mut root: serde_yaml::Value = serde_yaml::from_str(BASE).unwrap();
set_path(
&mut root,
&["settings", "providers", "deepseek", "base_url"],
serde_yaml::Value::String("https://api.deepseek.com".into()),
)
.unwrap();
set_path(
&mut root,
&["settings", "providers", "deepseek", "models"],
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("deepseek-chat".into())]),
)
.unwrap();
assert_eq!(root["settings"]["providers"]["deepseek"]["base_url"], "https://api.deepseek.com");
assert_eq!(root["settings"]["providers"]["deepseek"]["models"][0], "deepseek-chat");
}
#[test]
fn remove_path_deletes_the_leaf() {
let mut root: serde_yaml::Value =
serde_yaml::from_str("version: \"1.0\"\nsettings:\n providers:\n openai:\n base_url: x\nagents: []\n")
.unwrap();
remove_path(&mut root, &["settings", "providers", "openai"]).unwrap();
assert!(root["settings"]["providers"].get("openai").is_none());
}
#[test]
fn add_then_reload_exposes_the_provider() {
let (app, cfg) = app_with_config(BASE);
add(
&app,
"deepseek",
"https://api.deepseek.com",
Some("deepseek-chat"),
Some("deepseek-chat, deepseek-reasoner"),
)
.unwrap();
// A fresh load of the same config sees the provider.
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
let def = providers::get(&reloaded.config, "deepseek").expect("provider persisted");
assert_eq!(def.base_url, "https://api.deepseek.com");
assert_eq!(def.default_model.as_deref(), Some("deepseek-chat"));
assert_eq!(def.models, vec!["deepseek-chat", "deepseek-reasoner"]);
}
#[test]
fn add_rejects_an_invalid_name_or_missing_url() {
let (app, _cfg) = app_with_config(BASE);
assert!(add(&app, "OpenAI", "https://x", None, None).is_err());
assert!(add(&app, "openai", " ", None, None).is_err());
}
#[test]
fn remove_and_set_default_flow() {
// Config mutations apply on the next process (same convention as
// alias/config set): reload between operations like a real CLI user.
let (app, cfg) = app_with_config(BASE);
add(&app, "openai", "https://api.openai.com/v1", Some("gpt-5.2"), None).unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
set_default(&reloaded, "openai").unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
assert_eq!(providers::default_name(&reloaded.config), Some("openai"));
// Cannot remove the default provider.
assert!(remove(&reloaded, "openai").is_err());
// Removing an unknown provider is an error too.
assert!(remove(&reloaded, "nope").is_err());
// After changing the default, removal works.
add(&reloaded, "deepseek", "https://api.deepseek.com", None, None).unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
set_default(&reloaded, "deepseek").unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
assert!(remove(&reloaded, "openai").is_ok());
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded2 = App::from_cli(cli2).unwrap();
assert!(providers::get(&reloaded2.config, "openai").is_none());
assert_eq!(providers::default_name(&reloaded2.config), Some("deepseek"));
}
#[test]
fn dry_run_writes_nothing() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, BASE).unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--dry-run", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
// "localai" is not shipped in the embedded default catalog, so its
// presence proves a real write happened.
add(&app, "localai", "http://127.0.0.1:8080/v1", None, None).unwrap();
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli2).unwrap();
assert!(providers::get(&reloaded.config, "localai").is_none());
}
}
+29
View File
@@ -0,0 +1,29 @@
//! am registry — community registry of agent catalogs (issue #77).
//! publish (manifest), search (across settings.registry.sources),
//! install (manifest + checksum + explicit trust), list.
use crate::app::App;
use anyhow::Result;
pub fn run(app: &App, sub: Option<&crate::cli::RegistryCmd>) -> Result<i32> {
match sub {
None => crate::registry::list(app),
Some(crate::cli::RegistryCmd::List) => crate::registry::list(app),
Some(crate::cli::RegistryCmd::Publish {
catalog,
source,
author,
version,
}) => crate::registry::publish(
app,
catalog,
source.as_deref(),
author.as_deref(),
version.as_deref(),
),
Some(crate::cli::RegistryCmd::Search { query }) => crate::registry::search(app, query),
Some(crate::cli::RegistryCmd::Install { url, yes }) => {
crate::registry::install(app, url, *yes)
}
}
}
+419 -8
View File
@@ -69,18 +69,61 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
&extra_args,
&extra_env,
)?;
// --model (issue #71): resolve against the local runtimes, then
// transmit through the agent's declared channel (env var or flag).
// --provider (issue #92): resolve from the LLM provider registry.
let mut extra_args = extra_args;
let mut extra_env = extra_env;
if let Some(p) = opts.provider.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_provider(app, agent, Some(p), opts.model.as_deref())?
.expect("a provider flag was given: apply_provider always resolves or errors");
extra_args.extend(a);
extra_env.extend(e);
} else if let Some(m) = opts.model.as_deref() {
let agent = require_agent(app, &target)?;
match apply_model(app, agent, m) {
Ok((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
Some((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
None => return Err(local_err),
},
}
}
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
// Issue #57: --parallel launches every member back-to-back without
// waiting (the OS spawns them concurrently); the default waits each
// health check before the next member.
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
start_one(app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, cwd)?;
if !opts.parallel {
// Issue #57: block until healthy before the next member.
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
}
}
return Ok(0);
}
let agent = require_agent(app, &target)?;
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd)
start_one(
app,
agent,
&extra_args,
&extra_env,
opts.notify,
opts.background,
opts.no_sandbox,
cwd,
)
}
fn start_one(
@@ -90,6 +133,7 @@ fn start_one(
extra_env: &BTreeMap<String, String>,
notify: bool,
background: bool,
no_sandbox: bool,
cwd: Option<&std::path::Path>,
) -> Result<i32> {
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
@@ -99,7 +143,10 @@ fn start_one(
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(&current));
if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?;
// Issue #79: sandbox enforcement before spawning.
let mut env = exec.env.clone();
crate::sandbox::enforce(app, agent, &exec.program, &mut env, no_sandbox)?;
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &env, cwd)?;
if app.dry_run() {
return Ok(0);
}
@@ -289,20 +336,77 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
stop_one(app, m, force, timeout)?;
}
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?;
start_one(
app,
m,
&extra_args,
&extra_env,
opts.notify,
true,
opts.no_sandbox,
None,
)?;
}
return Ok(0);
}
let agent = require_agent(app, &target)?;
stop_one(app, agent, force, timeout)?;
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None)
start_one(
app,
agent,
&extra_args,
&extra_env,
opts.notify,
opts.background,
opts.no_sandbox,
None,
)
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
pub fn run(
app: &App,
target: &str,
model: Option<&str>,
provider: Option<&str>,
container: bool,
no_sandbox: bool,
extra: &[OsString],
) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
// Issue #58: --container runs the agent inside its container profile.
if container {
return run_container(app, agent, extra);
}
let mut extra_args: Vec<String> = extra
.iter()
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
// Issue #92: --provider resolves from the LLM provider registry (cloud);
// --model alone keeps the local-runtimes-first behavior (issue #71) with
// a cloud fallback when the model is unknown locally.
if let Some(p) = provider {
let (a, e) = apply_provider(app, agent, Some(p), model)?
.expect("a provider flag was given: apply_provider always resolves or errors");
extra_args.extend(a);
extra_env.extend(e);
} else if let Some(m) = model {
match apply_model(app, agent, m) {
Ok((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
Some((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
None => return Err(local_err),
},
}
}
let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
@@ -315,6 +419,9 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
// Issue #79: sandbox enforcement (command allowlist, cwd perimeter,
// network policy) — refused attempts are journalized for audit.
crate::sandbox::enforce(app, agent, &prog, &mut exec.env, no_sandbox)?;
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
@@ -334,11 +441,174 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
Ok(code)
}
/// Issue #58: run the agent inside its container profile. Detects the
/// runtime (podman > docker), mounts state/log dirs and the profile
/// volumes/network/env, then execs the container in the foreground.
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
let Some(spec) = &agent.container else {
bail!(
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
agent.name
);
};
let Some(runtime) = crate::automation::detect_runtime() else {
bail!(
"no container runtime found (docker or podman) — install one or drop --container"
);
};
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
let log_dir = &app.paths.log_dir;
let mut cmd = crate::automation::container_command(
&runtime,
&agent.name,
spec,
state_dir,
log_dir,
)?;
// The agent's run command + extra args become the container command.
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
cmd.push(exec.program);
cmd.extend(exec.args.iter().cloned());
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
if app.dry_run() {
app.log.dry(&format!("would run: {}", cmd.join(" ")));
return Ok(0);
}
app.log.info(&format!(
"running '{}' in a container ({runtime})",
agent.name
));
let status = Command::new(&cmd[0])
.args(&cmd[1..])
.status()
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_reason(format!("container ({runtime})"))
.with_args(cmd.clone())
.with_exit_code(code),
);
let _ = app.state.touch(&agent.name);
Ok(code)
}
/// Current directory as a string (None when unavailable).
fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string())
}
/// Resolve --model against the local runtimes and build the transmission
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
/// model is an explicit error listing the available runtimes.
pub fn apply_model(
app: &App,
agent: &AgentDef,
model: &str,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let inventory = crate::models::detect(app);
if inventory.find(model).is_none() {
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
let hint = if known.is_empty() {
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
} else {
format!("available models: {} — run 'am models' for details", known.join(", "))
};
bail!("model '{model}' not found on any local runtime ({hint})");
}
let mut args = Vec::new();
let mut env = BTreeMap::new();
if let Some(var) = &agent.model_env {
env.insert(var.clone(), model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {var}",
agent.name
));
} else if let Some(flag) = &agent.model_arg {
args.push(flag.clone());
args.push(model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {flag}",
agent.name
));
} else {
// Generic fallback: many local agents read AM_MODEL; the config can
// pin a specific channel (model_env / model_arg) instead.
env.insert("AM_MODEL".to_string(), model.to_string());
app.log.info(&format!(
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
agent.name
));
}
Ok((args, env))
}
/// Issue #92: resolve `--provider`/`--model` against the LLM provider
/// registry (cloud) and transmit through the agent's declared channels:
/// `model_env` / `model_arg`, or the `config.env_map` slots (issue #91).
/// The token travels as the `@secret` reference resolved by
/// resolve_env_secrets — never on the command line. Returns None when no
/// provider is configured at all (the caller falls back to the
/// local-runtime path).
fn apply_provider(
app: &App,
agent: &AgentDef,
provider: Option<&str>,
model: Option<&str>,
) -> Result<Option<(Vec<String>, BTreeMap<String, String>)>> {
let Some((pname, _def, resolved_model)) = crate::providers::resolve_for(
&app.config,
crate::agent_config::provider_pref(agent),
agent.model.as_deref(),
provider,
model,
) else {
if provider.is_some() {
let known = crate::providers::names(&app.config);
let hint = if known.is_empty() {
"aucun (am providers add <nom> --base-url <url>)".to_string()
} else {
known.join(", ")
};
bail!(crate::tr_fmt!(
"provider '{}' inconnu — providers enregistrés: {}",
provider.unwrap_or(""),
hint
));
}
return Ok(None);
};
let mut args: Vec<String> = Vec::new();
let mut env: BTreeMap<String, String> = BTreeMap::new();
// env_map resolved against THIS provider (api_key stays @secret).
crate::agent_config::apply_env_map_resolved(
&mut env,
agent,
&app.config,
Some(pname),
resolved_model,
);
// The model through the agent's declared channel (model_env > model_arg);
// otherwise the env_map "model" slot already carried it.
if let Some(m) = resolved_model {
if let Some(var) = &agent.model_env {
env.insert(var.clone(), m.to_string());
} else if let Some(flag) = &agent.model_arg {
args.push(flag.clone());
args.push(m.to_string());
}
}
app.log.info(&crate::tr_fmt!(
"provider {} (modèle {}) au lancement de {}",
pname,
resolved_model.unwrap_or("-"),
agent.name
));
Ok(Some((args, env)))
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
@@ -348,3 +618,144 @@ fn seconds_since(ts: Option<&str>) -> Option<u64> {
.num_seconds();
Some(secs.max(0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AgentConfig, AgentDef, Config};
use clap::Parser;
fn test_app(providers_yaml: &str) -> App {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n"
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
crate::app::App::from_cli(cli).unwrap()
}
fn agent(model_env: bool, model_arg: bool) -> AgentDef {
let mut env_map = BTreeMap::new();
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
env_map.insert("model".to_string(), "MY_MODEL".to_string());
env_map.insert("base_url".to_string(), "MY_URL".to_string());
AgentDef {
name: "demo".to_string(),
display_name: None,
description: None,
category: None,
website: None,
install: None,
dependencies: vec![],
run: Some("demo".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: model_env.then(|| "MY_MODEL".to_string()),
model_arg: model_arg.then(|| "--model".to_string()),
provider: None,
model: None,
config: Some(AgentConfig {
env_map,
files: vec![],
provider_default: None,
}),
setup_hints: vec![],
sandbox: None,
tags: vec![],
installable: false,
note: None,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
const OPENAI: &str = " default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n";
#[test]
fn apply_provider_resolves_and_never_leaks_the_token() {
let app = test_app(OPENAI);
let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2"))
.unwrap()
.unwrap();
// The token slot is the @secret reference — never a value.
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}");
assert!(args.is_empty());
}
#[test]
fn apply_provider_uses_the_declared_model_channel() {
let app = test_app(OPENAI);
// model_env wins over the env_map slot.
let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None)
.unwrap()
.unwrap();
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert!(args.is_empty());
// model_arg is pushed on the command line.
let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2"))
.unwrap()
.unwrap();
assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]);
}
#[test]
fn apply_provider_unknown_is_an_error_listing_the_registry() {
let app = test_app(OPENAI);
let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("nope"), "{msg}");
assert!(msg.contains("openai"), "registry list missing: {msg}");
}
#[test]
fn apply_provider_falls_back_to_the_embedded_default_provider() {
// The embedded default catalog ships providers (anthropic★ since
// issue #90): even an empty user config resolves through the cloud.
let app = test_app("");
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x"))
.unwrap()
.expect("the embedded default provider resolves");
// Model "x" is unknown: the env_map model slot is skipped, but the
// provider slot is wired.
assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1");
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
}
#[test]
fn model_flag_falls_back_to_the_cloud_registry() {
let app = test_app(OPENAI);
// apply_model bails (no local runtime in the test env) → the caller
// falls back to apply_provider with the default provider.
let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err();
assert!(local.to_string().contains("not found on any local runtime"));
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2"))
.unwrap()
.unwrap();
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
}
#[test]
fn config_parses_the_provider_registry_and_agents() {
let _cfg: Config = serde_yaml::from_str(&format!(
"version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n"
))
.unwrap();
}
}
+217
View File
@@ -0,0 +1,217 @@
//! schedule: plan am commands (axe 6, issue #56). Schedules live in
//! state.json; a daily OS-level task (crontab / Task Scheduler) points back
//! at 'am schedule-run <id>' which executes the stored command, records the
//! result and notifies on failure.
use super::*;
use crate::automation;
use crate::events::{Event, EventKind};
use crate::state::ScheduleEntry;
use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, action: &str, command: Vec<String>, at: Option<&str>) -> Result<i32> {
match action {
"add" => add(app, command, at),
"list" => list(app),
"remove" => {
let id = command
.first()
.ok_or_else(|| anyhow!("schedule remove requires an id"))?;
remove(app, id)
}
"run" => {
let id = command
.first()
.ok_or_else(|| anyhow!("schedule-run requires an id"))?;
run_one(app, id)
}
_ => bail!("usage: am schedule add <cmd...> | am schedule list | am schedule remove <id> | am schedule run <id>"),
}
}
fn task_name(id: &str) -> String {
format!("am-schedule-{id}")
}
/// Register the OS-level daily task (crontab on unix, schtasks on Windows).
/// Best effort: the schedule stays in state.json even if the OS layer
/// cannot be reached (warned, not fatal).
fn register_os_task(app: &App, entry: &ScheduleEntry) {
let am_bin = automation::am_bin_quoted();
let id = &entry.id;
if cfg!(windows) {
let status = automation::schtasks_daily(&entry.at, &am_bin, id, &task_name(id))
.status()
.unwrap_or_default();
if !status.success() {
app.log
.warn("schtasks failed — the schedule is stored but not registered at OS level");
}
} else {
// Append to the user crontab (dedup first).
let existing = Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if existing.contains(&format!("schedule-run {id}")) {
return;
}
let line = automation::crontab_line(&entry.at, &am_bin, id);
let mut next = existing.trim_end().to_string();
next.push('\n');
next.push_str(&line);
next.push('\n');
let mut child = Command::new("crontab")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("crontab spawn");
use std::io::Write;
let _ = child
.stdin
.as_mut()
.map(|s| s.write_all(next.as_bytes()));
let _ = child.wait();
}
}
fn add(app: &App, command: Vec<String>, at: Option<&str>) -> Result<i32> {
if command.is_empty() {
bail!("schedule add requires a command — ex: am schedule add update --all");
}
if app.dry_run() {
app.log.dry(&format!("would schedule: {}", command.join(" ")));
return Ok(0);
}
let id = crate::repl::session_id();
let entry = ScheduleEntry {
id,
cadence: "daily".to_string(),
command,
at: at.unwrap_or("08:00").to_string(),
created_at: crate::installers::now_rfc3339(),
last_run: None,
last_exit: None,
};
let mut sf = app.state.load()?;
sf.schedules.push(entry.clone());
app.state.save(&sf)?;
register_os_task(app, &entry);
app.emit(
&Event::now(EventKind::Config)
.with_agent("schedule".to_string())
.with_reason(format!("schedule add {} ({})", entry.id, entry.at)),
);
app.log.success(&format!(
"scheduled daily at {}: {} (id {})",
entry.at,
entry.command.join(" "),
entry.id
));
Ok(0)
}
fn list(app: &App) -> Result<i32> {
let sf = app.state.load()?;
if sf.schedules.is_empty() {
app.log.info("no schedule — 'am schedule add <cmd>' to create one (ex: am schedule add update --all)");
return Ok(0);
}
println!("schedules:");
for e in &sf.schedules {
let last = e
.last_run
.as_deref()
.map(|t| {
e.last_exit
.map(|c| format!("{t} (exit {c})"))
.unwrap_or_else(|| t.to_string())
})
.unwrap_or_else(|| "never".to_string());
println!(
" {} {} {} cmd: {} last: {}",
e.id,
e.cadence,
e.at,
e.command.join(" "),
last
);
}
Ok(0)
}
fn remove(app: &App, id: &str) -> Result<i32> {
let mut sf = app.state.load()?;
let before = sf.schedules.len();
sf.schedules.retain(|e| e.id != id);
if sf.schedules.len() == before {
bail!("unknown schedule '{id}' — 'am schedule list' shows them");
}
app.state.save(&sf)?;
// Best-effort OS cleanup.
if cfg!(windows) {
let _ = Command::new("schtasks")
.args(["/delete", "/tn", &task_name(id), "/f"])
.status();
} else {
let existing = Command::new("crontab")
.arg("-l")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let cleaned: Vec<&str> = existing
.lines()
.filter(|l| !l.contains(&format!("schedule-run {id}")))
.collect();
let text = format!("{}\n", cleaned.join("\n"));
let mut child = Command::new("crontab")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("crontab spawn");
use std::io::Write;
let _ = child
.stdin
.as_mut()
.map(|s| s.write_all(text.as_bytes()));
let _ = child.wait();
}
app.log.success(&format!("schedule '{id}' removed"));
Ok(0)
}
/// Execute one schedule (invoked by the OS task). Records last_run /
/// last_exit, emits a Schedule event and notifies on failure.
fn run_one(app: &App, id: &str) -> Result<i32> {
let sf = app.state.load()?;
let entry = sf
.schedules
.iter()
.find(|e| e.id == id)
.cloned()
.ok_or_else(|| anyhow!("unknown schedule '{id}'"))?;
app.log.info(&format!("running schedule {id}: {}", entry.command.join(" ")));
let code = automation::schedule_run_command(app, &entry)?;
// Record the outcome.
let mut sf = app.state.load()?;
if let Some(e) = sf.schedules.iter_mut().find(|e| e.id == id) {
e.last_run = Some(crate::installers::now_rfc3339());
e.last_exit = Some(code);
}
app.state.save(&sf)?;
app.emit(
&Event::now(EventKind::Run)
.with_agent("schedule".to_string())
.with_exit_code(code)
.with_reason(format!("schedule {id}: {}", entry.command.join(" "))),
);
if code == 0 {
app.log.success(&format!("schedule {id} completed"));
} else {
app.log.error(&format!(
"schedule {id} FAILED (exit {code}) — command: {}",
entry.command.join(" ")
));
}
Ok(code)
}
+40 -8
View File
@@ -1,34 +1,65 @@
//! secret: manage secrets in the OS keyring (values never shown or logged).
//! Secrets are scoped to an agent (`agent/NAME`, issue #36) or to a provider
//! (`providers/<provider>/<VAR>`, issue #89) — the provider namespace is
//! shared by every agent of that provider.
use super::*;
use crate::cli::SecretCmd;
use crate::output::print_json;
use crate::secrets::{self, SecretStore};
/// Resolve the keyring key and the display scope for a secret name.
/// Exactly one of (agent, provider) must be given.
fn scope_key(
name: &str,
agent: Option<&str>,
provider: Option<&str>,
) -> Result<(String, String)> {
match (provider, agent) {
(Some(p), _) => Ok((secrets::key_for_provider(p, name), format!("provider {p}"))),
(None, Some(a)) => Ok((secrets::key_for(a, name), format!("agent {a}"))),
(None, None) => bail!(
"{}",
crate::i18n::tr(
"usage: secret set <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>"
)
),
}
}
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
match sub {
SecretCmd::Set { name, agent, value } => {
SecretCmd::Set {
name,
agent,
provider,
value,
} => {
let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?;
if app.dry_run() {
app.log.dry(format!("would store secret {name} for {agent} in the OS keyring"));
app.log.dry(format!("would store secret {name} for {scope} in the OS keyring"));
return Ok(0);
}
let key = secrets::key_for(agent, name);
secrets::store().set(&key, value)?;
app.log.success(&format!("secret {name} stored for {agent}"));
app.log.success(&crate::tr_fmt!("secret '{}' stocké pour {}", name, scope));
app.log.info("use it with --env NAME=@secret (injected at start/run)");
Ok(0)
}
SecretCmd::Unset { name, agent } => {
let key = secrets::key_for(agent, name);
SecretCmd::Unset {
name,
agent,
provider,
} => {
let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?;
if app.dry_run() {
app.log.dry(format!("would remove secret {key}"));
return Ok(0);
}
let removed = secrets::store().remove(&key)?;
if removed {
app.log.success(&format!("secret {name} removed for {agent}"));
app.log.success(&crate::tr_fmt!("secret '{}' supprimé pour {}", name, scope));
} else {
app.log.info(&format!("secret {name} does not exist for {agent}"));
app.log.info(&crate::tr_fmt!("secret '{}' inexistant pour {}", name, scope));
}
Ok(0)
}
@@ -42,6 +73,7 @@ pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ...");
return Ok(0);
}
// Both namespaces appear here: agent/NAME and providers/<p>/<VAR>.
let mut table = crate::output::Table::new(vec!["SECRET"]);
for k in all {
if k != "__index__" {
+10 -4
View File
@@ -109,16 +109,22 @@ pub fn run(app: &App) -> Result<i32> {
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()
crate::tr_fmt!(
"{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
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()
crate::tr_fmt!(
"{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
locked.len() - scheduled.len()
)
);
}
}
+74 -4
View File
@@ -4,7 +4,7 @@ use super::*;
use crate::download;
use anyhow::{anyhow, bail, Context, Result};
use std::cmp::Ordering;
use std::path::Path;
use std::path::{Path, PathBuf};
pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
let Some(repo) = app.config.settings.self_update_repo.clone() else {
@@ -51,19 +51,26 @@ pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
let current_exe = std::env::current_exe()?;
let dest = to.map(|p| p.to_path_buf()).unwrap_or_else(|| current_exe.clone());
let tmpdir = tempfile::tempdir()?;
let tmp = tmpdir.path().join("am.new");
let tmp = tmpdir.path().join("am.dl");
download::download(&picked.url, &tmp, None, true)?;
// Release assets are archives (zip on Windows, tar.gz elsewhere):
// unpack them and locate the binary inside.
let staged = if download::extract_archive(&tmp, tmpdir.path())? {
find_binary(tmpdir.path())?
} else {
tmp
};
if dest == current_exe && cfg!(windows) {
// The running executable is locked on Windows.
let new_path = current_exe.with_extension("exe.new");
std::fs::copy(&tmp, &new_path)
std::fs::copy(&staged, &new_path)
.with_context(|| format!("cannot write {}", new_path.display()))?;
app.log.success(&format!(
"downloaded {tag} to {} — replace the running binary manually",
new_path.display()
));
} else {
std::fs::copy(&tmp, &dest)
std::fs::copy(&staged, &dest)
.with_context(|| format!("cannot write {}", dest.display()))?;
#[cfg(unix)]
{
@@ -79,3 +86,66 @@ pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
Ok(0)
}
}
/// Locate the binary inside an unpacked release archive.
fn find_binary(dir: &Path) -> Result<PathBuf> {
let mut entries: Vec<PathBuf> = Vec::new();
collect_files(dir, &mut entries);
if entries.is_empty() {
bail!("no binary found in the release archive");
}
// Prefer a file named exactly `am` / `am.exe`, otherwise the largest
// file (the archive only contains the binary).
entries.sort_by_key(|p| {
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
let prefer = name == "am" || name == "am.exe" || name.ends_with(".exe");
(
!prefer,
std::cmp::Reverse(std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)),
)
});
Ok(entries.remove(0))
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
collect_files(&p, out);
} else {
out.push(p);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_binary_prefers_am_exe_in_zip_layout() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("README.md"), "docs").unwrap();
std::fs::write(dir.path().join("am-windows-x86_64.exe"), b"exe").unwrap();
std::fs::write(dir.path().join("am-linux-x86_64"), b"elf").unwrap();
let found = find_binary(dir.path()).unwrap();
assert_eq!(
found.file_name().unwrap().to_str().unwrap(),
"am-windows-x86_64.exe"
);
}
#[test]
fn find_binary_falls_back_to_largest_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("am"), b"small").unwrap();
std::fs::write(dir.path().join("data.bin"), vec![0u8; 4096]).unwrap();
let found = find_binary(dir.path()).unwrap();
assert_eq!(found.file_name().unwrap().to_str().unwrap(), "am");
}
}
+17
View File
@@ -0,0 +1,17 @@
//! am serve — authenticated HTTP + WebSocket API (issue #80).
use super::*;
use crate::cli::ServeArgs;
pub fn run(app: &App, args: &ServeArgs) -> Result<i32> {
let Some(token) = args.token.clone() else {
anyhow::bail!("am serve exige --token <token> — le serveur refuse toute requête sans ce token");
};
crate::serve::run(
app,
&token,
args.host.as_deref().unwrap_or("127.0.0.1"),
args.port.unwrap_or(crate::serve::DEFAULT_PORT),
args.rate_limit.unwrap_or(crate::serve::DEFAULT_RATE_LIMIT),
)
}
+206
View File
@@ -0,0 +1,206 @@
//! service: register an agent as a system service with autostart (issue #55).
//! systemd user unit on Linux, launchd LaunchAgent on macOS, Task Scheduler
//! on Windows. Logs go through the existing 'am start --background' runner.
use super::*;
use crate::automation;
use crate::events::{Event, EventKind};
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use std::process::Command;
pub fn run(app: &App, action: &str, agent: Option<&str>, autostart: bool) -> Result<i32> {
match action {
"install" => {
let name = agent.ok_or_else(|| anyhow!("service install requires an agent name"))?;
install(app, name, autostart)
}
"uninstall" => {
let name = agent.ok_or_else(|| anyhow!("service uninstall requires an agent name"))?;
uninstall(app, name)
}
"status" => {
let name = agent.ok_or_else(|| anyhow!("service status requires an agent name"))?;
status(app, name)
}
_ => bail!("usage: am service install|uninstall|status <agent> [--autostart]"),
}
}
fn unit_path(agent: &str) -> PathBuf {
if cfg!(target_os = "linux") {
automation::systemd_unit_path(agent)
} else if cfg!(target_os = "macos") {
automation::launchd_path(agent)
} else {
// Windows: no file — the task lives in the Task Scheduler.
PathBuf::new()
}
}
fn install(app: &App, agent: &str, autostart: bool) -> Result<i32> {
require_agent(app, agent)?;
if app.dry_run() {
app.log
.dry(format!("would install '{agent}' as a system service (autostart: {autostart})"));
return Ok(0);
}
let am_bin = automation::am_bin_quoted();
let mut wrote = String::new();
if cfg!(target_os = "linux") {
let path = automation::systemd_unit_path(agent);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&path, automation::systemd_unit(agent, &am_bin))
.with_context(|| format!("cannot write {}", path.display()))?;
wrote = path.display().to_string();
// Enable + start the user unit.
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
if autostart {
let _ = Command::new("systemctl")
.args(["--user", "enable", &format!("am-{agent}.service")])
.status();
}
let _ = Command::new("systemctl")
.args(["--user", "start", &format!("am-{agent}.service")])
.status();
} else if cfg!(target_os = "macos") {
let path = automation::launchd_path(agent);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&path, automation::launchd_plist(agent, &am_bin))
.with_context(|| format!("cannot write {}", path.display()))?;
wrote = path.display().to_string();
if autostart {
let _ = Command::new("launchctl")
.args(["load", path.to_str().unwrap_or_default()])
.status();
}
} else {
// Windows: prefer a logon task. /sc onlogon needs admin on some
// setups; fall back to the Startup folder (no admin required).
let task = automation::windows_task_name(agent);
let am_bin = std::env::current_exe().unwrap_or_default();
let tr = format!("\"{}\" start {} --background --notify", am_bin.display(), agent);
let status = Command::new("schtasks")
.args(["/create", "/tn", &task, "/sc", "onlogon", "/tr", &tr, "/f"])
.status()?;
if status.success() {
wrote = format!("Task Scheduler: {task}");
} else {
let startup = crate::config::home_dir()
.map(|h| {
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
})
.unwrap_or_default();
let script = startup.join(format!("am-{agent}.cmd"));
if let Some(p) = script.parent() {
std::fs::create_dir_all(p)
.with_context(|| format!("cannot create {}", p.display()))?;
}
std::fs::write(&script, format!("@echo off\r\nstart \"\" /b {tr}\r\n"))
.with_context(|| format!("cannot write {}", script.display()))?;
wrote = format!("Startup folder: {}", script.display());
}
}
app.emit(
&Event::now(EventKind::Config)
.with_agent(agent.to_string())
.with_reason(format!("service install (autostart: {autostart})")),
);
app.log.success(&format!(
"service '{agent}' installed{} ({wrote})",
if autostart { " with autostart" } else { "" }
));
Ok(0)
}
fn uninstall(app: &App, agent: &str) -> Result<i32> {
if app.dry_run() {
app.log.dry(format!("would remove the service for '{agent}'"));
return Ok(0);
}
let mut removed = String::new();
if cfg!(target_os = "linux") {
let _ = Command::new("systemctl")
.args(["--user", "stop", &format!("am-{agent}.service")])
.status();
if automation::systemd_unit_path(agent).exists() {
let _ = Command::new("systemctl")
.args(["--user", "disable", &format!("am-{agent}.service")])
.status();
std::fs::remove_file(automation::systemd_unit_path(agent))?;
}
let _ = Command::new("systemctl").args(["--user", "daemon-reload"]).status();
removed = "systemd unit removed".to_string();
} else if cfg!(target_os = "macos") {
let path = automation::launchd_path(agent);
if path.exists() {
let _ = Command::new("launchctl").args(["unload", path.to_str().unwrap_or_default()]).status();
std::fs::remove_file(&path)?;
}
removed = "launchd plist removed".to_string();
} else {
let task = automation::windows_task_name(agent);
let _ = Command::new("schtasks")
.args(["/delete", "/tn", &task, "/f"])
.status();
let mut parts = vec![format!("Task Scheduler: {task}")];
// Also remove the Startup folder fallback if present.
if let Some(h) = crate::config::home_dir() {
let script = h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
.join(format!("am-{agent}.cmd"));
if script.exists() {
std::fs::remove_file(&script).with_context(|| {
format!("cannot remove {}", script.display())
})?;
parts.push("Startup script removed".to_string());
}
}
removed = parts.join(" · ");
}
app.emit(
&Event::now(EventKind::Config)
.with_agent(agent.to_string())
.with_reason("service uninstall"),
);
app.log.success(&format!("service '{agent}' removed ({removed})"));
Ok(0)
}
fn status(app: &App, agent: &str) -> Result<i32> {
let mut installed = false;
if cfg!(target_os = "linux") {
installed = automation::systemd_unit_path(agent).exists();
} else if cfg!(target_os = "macos") {
installed = automation::launchd_path(agent).exists();
} else {
let task = automation::windows_task_name(agent);
let status = Command::new("schtasks")
.args(["/query", "/tn", &task])
.status()
.unwrap_or_default();
installed = status.success();
if !installed {
// Startup folder fallback?
installed = crate::config::home_dir()
.map(|h| {
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
.join(format!("am-{agent}.cmd"))
.exists()
})
.unwrap_or(false);
}
}
if installed {
println!("service '{agent}': installed");
Ok(0)
} else {
app.log.info(&format!("service '{agent}': not installed"));
Ok(1)
}
}
+97 -24
View File
@@ -4,21 +4,22 @@ use super::*;
use crate::output::{print_json, status_style, Table};
use crate::sessions::{self, SessionRecord};
use serde::Serialize;
use std::path::PathBuf;
#[derive(Serialize)]
struct SessionRow {
id: String,
kind: String,
agent: Option<String>,
status: String,
pid: Option<u32>,
cwd: Option<String>,
project: Option<String>,
started_at: String,
ended_at: Option<String>,
duration_s: Option<u64>,
exit_code: Option<i32>,
log: Option<String>,
pub struct SessionRow {
pub id: String,
pub kind: String,
pub agent: Option<String>,
pub status: String,
pub pid: Option<u32>,
pub cwd: Option<String>,
pub project: Option<String>,
pub started_at: String,
pub ended_at: Option<String>,
pub duration_s: Option<u64>,
pub exit_code: Option<i32>,
pub log: Option<String>,
}
fn row_of(r: &SessionRecord) -> SessionRow {
@@ -38,21 +39,14 @@ fn row_of(r: &SessionRecord) -> SessionRow {
}
}
pub fn run(
/// Rows for the web API (issue #54): same shape as `am sessions --json`.
/// Unlike `run`, it never reconciles the registry (the web is read-only).
pub fn list_rows(
app: &App,
agent: Option<&str>,
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
resume: Option<&str>,
) -> Result<i32> {
if let Some(id) = show {
return show_one(app, id);
}
if let Some(id) = resume {
return resume_session(app, id);
}
sessions::reconcile(app)?;
) -> Result<Vec<SessionRow>> {
let sf = sessions::load(app)?;
let mut rows: Vec<SessionRow> = sf
.sessions
@@ -63,6 +57,34 @@ pub fn run(
.map(row_of)
.collect();
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(rows)
}
pub fn run(
app: &App,
agent: Option<&str>,
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
resume: Option<&str>,
export: Option<&str>,
output: Option<&str>,
retention: Option<u64>,
) -> Result<i32> {
if let Some(id) = export {
return export_one(app, id, output);
}
if let Some(days) = retention {
return purge(app, days);
}
if let Some(id) = show {
return show_one(app, id);
}
if let Some(id) = resume {
return resume_session(app, id);
}
sessions::reconcile(app)?;
let rows = list_rows(app, agent, project, status)?;
if app.json() {
print_json(&rows);
return Ok(0);
@@ -181,6 +203,57 @@ fn short_id(id: &str) -> String {
id.chars().take(15).collect()
}
/// Issue #53: 'am sessions export <id> [--output <file>]' — write a
/// complete, self-contained JSON export (metadata, commands, log excerpt)
/// plus a human-readable rendering. Default destination:
/// ./am-session-<id>.json in the current directory.
fn export_one(app: &App, id: &str, output: Option<&str>) -> Result<i32> {
let export = sessions::export_session(app, id)?;
let dest = match output {
Some(o) => PathBuf::from(o),
None => std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!("am-session-{}.json", export.session.id)),
};
let text = serde_json::to_string_pretty(&export)?;
std::fs::write(&dest, text).map_err(|e| {
anyhow::anyhow!("cannot write export {}: {e}", dest.display())
})?;
app.log.success(&format!(
"exported session {} ({} command(s), {} log bytes) to {}",
export.session.id,
export.commands.len(),
export.log_excerpt.len(),
dest.display()
));
app.log.info(&format!(
"JSON complet + rendu lisible ('readable') — compatible archivage manuel / am import"
));
Ok(0)
}
/// Issue #53: 'am sessions --retention <days>' — purge finished sessions
/// older than the threshold. The primary event journal is untouched; the
/// purge is journaled as EventKind::Prune.
fn purge(app: &App, days: u64) -> Result<i32> {
if days == 0 {
bail!("retention must be at least 1 day");
}
if app.dry_run() {
app.log
.dry(format!("would purge sessions finished more than {days} days ago"));
return Ok(0);
}
let purged = sessions::purge_expired(app, days)?;
match purged {
0 => app.log.info(&format!(
"no session finished more than {days} days ago — nothing to purge"
)),
n => app.log.success(&format!("purged {n} session(s) older than {days} days")),
}
Ok(0)
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
+73 -6
View File
@@ -119,12 +119,25 @@ pub fn report_md(app: &App, days: u32) -> String {
}
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
let top_rows = top(app, None).unwrap_or_default();
let mut md = format!(
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
duration
);
// Issue #74: the report follows the active language.
let md = if app.lang().is_en() {
format!(
"# agent-manager report — last {days} days\n\n- Launches: {total_launches}\n- Failures: {failures}\n- Changes (install/update/uninstall): {changes}\n- Cumulative duration: {}s\n- Recorded sessions: {sessions}\n",
duration
)
} else {
format!(
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
duration
)
};
let mut md = md;
if !top_rows.is_empty() {
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
md.push_str(if app.lang().is_en() {
"\n## Top agents\n\n| Agent | Launches | Duration | Failures |\n|---|---|---|---|\n"
} else {
"\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n"
});
for r in &top_rows {
md.push_str(&format!(
"| {} | {} | {}s | {} |\n",
@@ -132,10 +145,30 @@ pub fn report_md(app: &App, days: u32) -> String {
));
}
}
// Issue #49: cost section when usage was recorded.
if let Ok(rows) = crate::costs::aggregate(app, Some(&format!("{days}d"))) {
if !rows.is_empty() {
let t = crate::costs::totals(&rows);
md.push_str(&format!(
"{}\n\n- {}: {}\n- {}: {} in / {} out\n- {}: ${:.4}\n",
if app.lang().is_en() { "\n## Estimated costs" } else { "\n## Coûts estimés" },
if app.lang().is_en() { "Billed sessions" } else { "Sessions facturées" },
t.sessions,
if app.lang().is_en() { "Tokens" } else { "Tokens" },
t.tokens_in,
t.tokens_out,
if app.lang().is_en() { "Estimated cost" } else { "Coût estimé" },
t.cost_usd
));
}
}
md
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>, costs: bool) -> Result<i32> {
if costs {
return run_costs(app, period);
}
let rows = aggregate(app, agent, period)?;
if app.json() {
print_json(&rows);
@@ -201,6 +234,40 @@ pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
Ok(0)
}
/// am stats --costs : token usage and estimated spend (issue #49).
pub fn run_costs(app: &App, period: Option<&str>) -> Result<i32> {
let rows = crate::costs::aggregate(app, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
if rows.is_empty() {
app.log
.info("no cost recorded yet — agents exposing their usage (claude-code) are priced at session end");
return Ok(0);
}
let total = crate::costs::totals(&rows);
let mut table = Table::new(vec!["AGENT", "SESSIONS", "TOKENS IN", "TOKENS OUT", "COST $"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
r.sessions.to_string(),
r.tokens_in.to_string(),
r.tokens_out.to_string(),
format!("{:.4}", r.cost_usd),
]);
}
table.row(vec![
"TOTAL".to_string(),
total.sessions.to_string(),
total.tokens_in.to_string(),
total.tokens_out.to_string(),
format!("{:.4}", total.cost_usd),
]);
print!("{}", table.render());
Ok(0)
}
/// am report : write the markdown digest (issue #37).
pub fn run_report(
app: &App,
+243
View File
@@ -0,0 +1,243 @@
//! suggest: recommend agents for a natural-language request (issue #61,
//! axe 7). Reuses the catalog fuzzy scoring (name/title/description/
//! category/tags) and boosts the ranking with real usage from the event
//! journal (launch counts and durations), with a short justification.
use super::*;
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
/// Top N suggestions shown.
const TOP_N: usize = 5;
#[derive(Serialize)]
struct SuggestRow {
name: String,
display_name: String,
category: Option<String>,
/// Combined score (fuzzy * usage boost).
score: f64,
/// Why this agent was suggested.
reason: String,
/// Real usage in the journal (when available).
launches: u64,
total_duration_s: u64,
}
pub fn run(app: &App, query: &str) -> Result<i32> {
let query = query.trim();
if query.is_empty() {
anyhow::bail!("empty query — describe what you need, e.g. 'am suggest un agent pour du Python'");
}
// 1. Fuzzy scoring over the catalog (name x3, title x2, rest x1).
let scored: Vec<(u32, &AgentDef)> = app
.catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter_map(|a| {
let kw = query.to_lowercase();
let name = a.name.to_lowercase();
let title = a.title().to_lowercase();
let rest = format!(
"{} {} {}",
a.description.as_deref().unwrap_or("").to_lowercase(),
a.category.as_deref().unwrap_or("").to_lowercase(),
a.tags.join(" ").to_lowercase()
);
let score = score_token(&kw, &name)
.saturating_mul(3)
.saturating_add(score_token(&kw, &title).saturating_mul(2))
.saturating_add(score_token(&kw, &rest));
(score > 0).then_some((score, a))
})
.collect();
// 2. Real usage boost from the journal (stats_cmd aggregates launches
// and durations per agent).
let usage = crate::commands::stats_cmd::aggregate(app, None, None).unwrap_or_default();
let launches: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.launches))
.collect();
let duration: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.total_duration_s))
.collect();
let mut rows: Vec<SuggestRow> = scored
.iter()
.map(|(score, a)| {
let l = launches.get(a.name.as_str()).copied().unwrap_or(0);
let d = duration.get(a.name.as_str()).copied().unwrap_or(0);
// Boost: up to +50 % for the most used agents (log scale).
let boost = if l > 0 {
1.0 + 0.5 * (1.0 + (l as f64).ln()).min(2.0) / 2.0
} else {
1.0
};
let final_score = (*score as f64) * boost;
let reason = justify(a, l, d);
SuggestRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
score: final_score,
reason,
launches: l,
total_duration_s: d,
}
})
.collect();
rows.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.name.cmp(&b.name))
});
rows.truncate(TOP_N);
if app.json() {
print_json(&rows);
return Ok(0);
}
if rows.is_empty() {
let suggestions = app.catalog.suggest(query);
if suggestions.is_empty() {
app.log.info(&format!(
"no agent matches '{query}' — try 'am list --all' to browse the catalog"
));
} else {
app.log.info(&format!(
"{} — {} : {} ?",
crate::tr_fmt!("no agent matches '{}'", query),
crate::i18n::tr("vouliez-vous dire"),
suggestions.join(", ")
));
}
return Ok(0);
}
println!(
"{} \"{query}\":",
crate::i18n::tr("suggestions for")
);
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.category.clone().unwrap_or_else(|| "—".to_string()),
r.reason.clone(),
]);
}
print!("{}", table.render());
if rows.len() < scored.len() {
println!(
"\n ({} other match(es) not shown — 'am search {}' lists them)",
scored.len() - rows.len(),
query
);
}
Ok(0)
}
/// Short justification: matched field(s) + real usage when available.
fn justify(a: &AgentDef, launches: u64, duration_s: u64) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = &a.category {
parts.push(format!("catégorie {c}"));
}
if !a.tags.is_empty() {
parts.push(format!("tags: {}", a.tags.join(", ")));
}
if launches > 0 {
parts.push(format!("{launches} lancement(s)"));
if duration_s >= 3600 {
parts.push(format!("{}h", duration_s / 3600));
}
}
if parts.is_empty() {
a.description
.as_deref()
.map(|d| {
let mut d = d.to_string();
d.truncate(60);
d
})
.unwrap_or_else(|| "correspond à la requête".to_string())
} else {
parts.join(" · ")
}
}
/// Reuse the catalog scoring helper (exported from catalog.rs).
fn score_token(keyword: &str, hay: &str) -> u32 {
crate::catalog::score_keyword(keyword, hay)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(name: &str, desc: &str, tags: &[&str]) -> AgentDef {
AgentDef {
name: name.to_string(),
display_name: None,
description: Some(desc.to_string()),
category: Some("coding-agent".to_string()),
website: None,
install: None,
dependencies: vec![],
run: Some(name.to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: None,
model_arg: None,
provider: None,
model: None,
config: None,
setup_hints: vec![],
sandbox: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false,
note: None,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
#[test]
fn scoring_prefers_relevant_agents() {
let a = agent("python-mate", "assistant python", &["python"]);
let b = agent("rust-mate", "assistant rust", &["rust"]);
let catalog = crate::catalog::Catalog::new(
vec![a, b],
BTreeMap::new(),
BTreeMap::new(),
);
let kw = "python";
let mut scored: Vec<(&str, u32)> = catalog
.agents()
.iter()
.filter_map(|x| {
let s = score_token(kw, &x.name.to_lowercase())
.saturating_mul(3)
.saturating_add(score_token(kw, &x.title().to_lowercase()).saturating_mul(2))
.saturating_add(score_token(
kw,
&format!("{} {}", x.description.clone().unwrap_or_default(), x.tags.join(" ")),
));
(s > 0).then_some((x.name.as_str(), s))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
assert_eq!(scored[0].0, "python-mate");
}
}
+26
View File
@@ -0,0 +1,26 @@
//! sync: push the runtime state (state.json, journal, history) into the
//! configured git repository (issue #66). Secrets are excluded by the
//! managed .gitignore; conflicts resolve "last writer wins" with a backup.
use super::*;
pub fn run(app: &App, message: Option<&str>) -> Result<i32> {
let msg = message.unwrap_or("am sync — state update").to_string();
match crate::sync::sync(app, &msg)? {
crate::sync::SyncOutcome::Skipped => {
app.log.info(
"no settings.sync_repo configured — add 'sync_repo: <url>' to the config (issue #66)",
);
Ok(0)
}
crate::sync::SyncOutcome::Clean => {
app.log.info("nothing to sync — state already committed");
Ok(0)
}
crate::sync::SyncOutcome::Pushed { files, message } => {
app.log
.success(&format!("state pushed ({files} file(s)): {message}"));
Ok(0)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
//! theme: switch the active color theme and persist it for the next start.
//!
//! With no argument, prints the current theme and the available list.
//! With a name, switches to that theme (also used by the REPL 'theme' command).
use super::*;
use crate::theme;
pub fn run(app: &App, name: Option<&str>) -> Result<i32> {
if let Some(name) = name {
let theme = theme::find(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown theme '{name}' — available: {}",
theme::names().join(", ")
)
})?;
app.set_theme(theme);
crate::commands::config_cmd::persist_setting(app, "settings.theme", theme.name)?;
app.log.success(&format!(
"theme set to {} ({})",
theme.name,
theme.title
));
return Ok(0);
}
// No argument: show current and list.
let current = app.theme();
if app.json() {
crate::output::print_json(&serde_json::json!({
"current": current.name,
"title": current.title,
"available": theme::names(),
}));
} else {
println!("current theme: {} — {}", current.name, current.title);
println!("available themes: {}", theme::names().join(", "));
println!("use 'am theme <name>' to switch");
}
Ok(0)
}
+795
View File
@@ -0,0 +1,795 @@
//! tip: a cheat sheet of the most useful am commands with their key
//! options — plus the user's own most-used commands (from the REPL
//! history) and a rotating "did you know" tip.
//!
//! 'am tip' prints the whole page; 'am tip --random' prints a single tip;
//! 'am tip --json' emits the same content as structured JSON.
use super::*;
use chrono::Datelike;
use crate::history::HistoryEntry;
use crate::theme::Theme;
use serde_json::json;
/// One cheat-sheet entry: usage line, one-line purpose, key options and a
/// concrete example.
pub struct TipEntry {
pub usage: &'static str,
pub about: &'static str,
pub options: &'static [(&'static str, &'static str)],
pub example: &'static str,
}
/// One section of the cheat sheet (icon + title + entries).
pub struct TipSection {
pub icon: &'static str,
pub title: &'static str,
pub entries: &'static [TipEntry],
}
/// The curated cheat sheet, ordered from everyday actions to expert tools.
pub static SECTIONS: &[TipSection] = &[
TipSection {
icon: "🚀",
title: "Lancer & piloter",
entries: &[
TipEntry {
usage: "start <agent>",
about: "démarre un agent (premier plan par défaut)",
options: &[
("-b, --background", "détaché en arrière-plan, sortie dans le log"),
("--args \"...\"", "arguments supplémentaires passés à l'agent"),
("--env KEY=VALUE", "variable d'environnement (répétable)"),
("--profile <nom>", "profil d'environnement (env + args)"),
],
example: "start claude-code --background",
},
TipEntry {
usage: "stop <agent>",
about: "arrête un agent en arrière-plan (SIGTERM puis SIGKILL)",
options: &[
("--force", "kill immédiat, sans délai de grâce"),
("--timeout <secs>", "délai de grâce avant le kill (défaut : 5 s)"),
],
example: "stop pi",
},
TipEntry {
usage: "restart <agent>",
about: "redémarre avec les mêmes options (agent, groupe, profil…)",
options: &[],
example: "restart group:dev",
},
TipEntry {
usage: "watch <agent>",
about: "supervise l'agent et le relance s'il meurt",
options: &[
("--restart", "relance automatique avec backoff"),
("--notify", "notification bureau à chaque relance"),
],
example: "watch smelt --restart",
},
TipEntry {
usage: "run <agent>",
about: "exécute la commande de l'agent directement (sans gestion de processus)",
options: &[("--args \"...\"", "arguments transmis tels quels à l'agent")],
example: "run claude-code --version",
},
],
},
TipSection {
icon: "📦",
title: "Installer & maintenir",
entries: &[
TipEntry {
usage: "list",
about: "agents installés (managed + external détectés sur le PATH)",
options: &[
("--all", "catalogue complet, y compris non installés"),
("--running", "uniquement les agents actifs"),
("--sort <key>", "name, version ou status"),
("--category <cat>", "filtre par catégorie"),
],
example: "list --all",
},
TipEntry {
usage: "search <mot>",
about: "cherche dans le catalogue (nom, description, catégorie, tags)",
options: &[],
example: "search pi",
},
TipEntry {
usage: "suggest <requête>",
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
options: &[("--json", "sortie machine-readable")],
example: "suggest un agent pour du Python",
},
TipEntry {
usage: "ask <demande>",
about: "langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (issue #78)",
options: &[("--yes", "exécute la commande sans confirmation")],
example: "ask installe claude et lance-le",
},
TipEntry {
usage: "ai <demande> [--exec] [--files <path>] [--role <rôle>]",
about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut",
options: &[
("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"),
("-f, --files", "fichiers/dossiers en contexte (défaut: dossier courant)"),
("--role", "rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
("--provider", "provider du registre (clé du keyring + modèle)"),
("--model", "forcer un modèle"),
("--yes", "exécute sans confirmation"),
],
example: "ai --exec \"compresse les fichiers JSON en un zip\"",
},
TipEntry {
usage: "setup [--roles]",
about: "wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat, rôles copilot am-* (v1.1.0)",
options: &[
("--roles", "régénérer uniquement les rôles copilot aichat"),
("--yes", "non-interactif (valeurs actuelles)"),
],
example: "setup",
},
TipEntry {
usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
options: &[
("--prune", "purge les modèles inutilisés"),
("--dry-run", "simulation sans rien supprimer"),
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
],
example: "models --prune --dry-run",
},
TipEntry {
usage: "catalog update",
about: "rafraîchit le catalogue officiel (diff + confirmation)",
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml",
},
TipEntry {
usage: "registry publish <catalogue.yaml> --source <url>",
about: "registre communautaire : publie, cherche ou installe un catalogue (manifeste + checksum sha256 vérifié)",
options: &[
("search <mot>", "cherche dans le registre"),
("install <url>", "installe après vérification du checksum et décision de confiance"),
],
example: "registry search agents python",
},
TipEntry {
usage: "audit",
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
options: &[("--json", "rapport machine-readable")],
example: "audit --json",
},
TipEntry {
usage: "service install <agent> --autostart",
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
options: &[
("status <agent>", "état du service"),
("uninstall <agent>", "retire le service"),
],
example: "service install claude-code --autostart",
},
TipEntry {
usage: "schedule add <cmd...> --at HH:MM",
about: "planifie une commande am quotidienne (ex: update --all le matin)",
options: &[
("schedule list", "liste les planifications"),
("schedule remove <id>", "supprime une planification"),
],
example: "schedule add update --all --at 06:30",
},
TipEntry {
usage: "doctor --watch <secondes>",
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
example: "doctor --watch 60",
},
TipEntry {
usage: "start group:dev --parallel",
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
example: "start group:dev --parallel",
},
TipEntry {
usage: "update --rollback",
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
options: &[
("--rollback list", "liste les points de restauration"),
("--rollback <id>", "restaure un point précis"),
],
example: "update --rollback list",
},
TipEntry {
usage: "init --template <stack>",
about: "génère une config projet pré-remplie (web, python, rust, cli)",
options: &[("--template list", "liste les templates disponibles")],
example: "init --template python",
},
TipEntry {
usage: "sessions --export <id>",
about: "exporte une session : métadonnées + commandes + extrait de log (JSON)",
options: &[
("--output <fichier>", "destination (défaut ./am-session-<id>.json)"),
("--retention <jours>", "purge les sessions terminées au-delà de N jours"),
],
example: "sessions --export 20260815_143926 --output session.json",
},
TipEntry {
usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
options: &[
("--method <m>", "choisit la méthode (index ou type:npm, binary…)"),
("--force", "réinstalle même si déjà présent"),
],
example: "install pi",
},
TipEntry {
usage: "update <agent>",
about: "met à jour un agent vers la dernière version disponible",
options: &[("--all", "tous les agents installés d'un coup")],
example: "update --all",
},
TipEntry {
usage: "uninstall <agent>",
about: "supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)",
options: &[("--purge", "supprime aussi les logs et la définition de la config")],
example: "uninstall pi",
},
],
},
TipSection {
icon: "👁️",
title: "Observer",
entries: &[
TipEntry {
usage: "status",
about: "état de tous les agents (running / installed / external)",
options: &[],
example: "status",
},
TipEntry {
usage: "logs <agent>",
about: "lit la fin du journal de l'agent",
options: &[
("--follow", "suit le log en direct"),
("--lines <N>", "nombre de lignes (défaut : 20)"),
],
example: "logs pi --follow",
},
TipEntry {
usage: "dashboard",
about: "TUI en direct : activité, stats, sessions, projets",
options: &[],
example: "dashboard",
},
TipEntry {
usage: "sessions · stats · top",
about: "historique des sessions · statistiques · agents les plus utilisés",
options: &[("--period <p>", "7d, 30d, 90d ou all"), ("stats --costs", "tokens + coût estimé par agent (issue #49)")],
example: "stats --period 30d --costs",
},
TipEntry {
usage: "monitor",
about: "TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils",
options: &[
("--interval <s>", "rafraîchissement (défaut 2s)"),
("--json", "flux JSON pour les scripts"),
],
example: "monitor --interval 5",
},
TipEntry {
usage: "web",
about: "dashboard web local en lecture seule : vue d'ensemble, graphiques, sessions, événements, projets (issue #54)",
options: &[
("--port <p>", "port d'écoute (défaut 7878)"),
("--no-open", "ne pas ouvrir le navigateur automatiquement"),
],
example: "web --port 9090",
},
TipEntry {
usage: "serve --token <tok>",
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask) (issue #80)",
options: &[
("--port <p>", "port d'écoute (défaut 8080)"),
("--host <h>", "adresse d'écoute (défaut 127.0.0.1)"),
("--rate-limit <n>", "requêtes par IP et par minute (défaut 120)"),
],
example: "serve --token mon-token --port 9000",
},
TipEntry {
usage: "sync",
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
options: &[("--message <m>", "message de commit personnalisé"), ("sync_on_exit: true", "pousse automatiquement à la fermeture du REPL")],
example: "sync --message \"sauvegarde du soir\"",
},
TipEntry {
usage: "migrate",
about: "export/import d'un bundle de transfert machine A → B (config + état + historique + backups)",
options: &[("--export", "crée agent-manager-migrate.amx"), ("--output <f>", "chemin du bundle")],
example: "migrate --export --output backup.amx",
},
TipEntry {
usage: "history 12..25 --save deploy.yaml",
about: "exporte une plage d'historique en playbook YAML (état_dir/playbooks/)",
options: &[("--save <f>", "fichier playbook"), ("playbook <f>", "rejoue pas à pas avec confirmation")],
example: "history 12..25 --save deploy.yaml && playbook deploy.yaml",
},
TipEntry {
usage: "lab --agents claude-code,aider --task hello.yaml",
about: "benchmark : même tâche sur plusieurs agents (durée, exit, coût)",
options: &[("--parallel", "exécution concurrente"), ("--json", "rapport comparatif stable (scripts/CI)")],
example: "lab --agents claude-code,aider --task bench/hello.yaml --json",
},
TipEntry {
usage: "plugins --test notify",
about: "scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)",
options: &[("--test <name>", "exécute un plugin sur un événement de test (CI)")],
example: "plugins && plugins --test notify",
},
TipEntry {
usage: "timeline",
about: "une vue chronologique de toute l'activité",
options: &[("--since <date>", "activité à partir d'une date")],
example: "timeline --since 2026-08-01",
},
],
},
TipSection {
icon: "⚙️",
title: "Personnaliser",
entries: &[
TipEntry {
usage: "alias add <nom> <agent>",
about: "crée un raccourci (ex. cc → claude-code)",
options: &[],
example: "alias add cc claude-code",
},
TipEntry {
usage: "profile",
about: "profils d'environnement réutilisables (env + args)",
options: &[],
example: "start pi --profile dev",
},
TipEntry {
usage: "config show",
about: "configuration effective (catalogue fusionné, alias, groupes…)",
options: &[],
example: "config show",
},
TipEntry {
usage: "theme <nom>",
about: "change la palette du shell, des tableaux et de cette page",
options: &[],
example: "theme ocean",
},
TipEntry {
usage: "secret set <nom> --agent <a> --value <v>",
about: "stocke un secret dans le keyring de l'OS, jamais en clair",
options: &[],
example: "secret set API_KEY --agent pi --value sk-…",
},
TipEntry {
usage: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring",
options: &[],
example: "providers set-token deepseek --value sk-…",
},
TipEntry {
usage: "favorite · note · tag",
about: "annotez vos agents : étoile, note libre, tags personnels",
options: &[],
example: "tag pi perso",
},
],
},
TipSection {
icon: "🧰",
title: "Boîte à outils",
entries: &[
TipEntry {
usage: "doctor --fix",
about: "diagnostique l'environnement et répare les problèmes courants",
options: &[],
example: "doctor --fix",
},
TipEntry {
usage: "export · import",
about: "sauvegarde config + état, puis restauration",
options: &[("--output <file>", "fichier de destination (défaut : am-export.json)")],
example: "export --output backup.json",
},
TipEntry {
usage: "completion <shell> --installed",
about: "complétion dynamique : agents installés, alias et groupes au Tab",
options: &[],
example: "completion powershell --installed",
},
TipEntry {
usage: "man <commande>",
about: "page de manuel pour une commande",
options: &[],
example: "man start",
},
TipEntry {
usage: "help --find <mot>",
about: "cherche une commande dans toute l'aide",
options: &[],
example: "help --find log",
},
TipEntry {
usage: "self-update",
about: "met à jour agent-manager lui-même",
options: &[("--check", "vérifie seulement si une version existe")],
example: "self-update --check",
},
],
},
];
/// Rotating pro tips ("Le saviez-vous ?").
const DID_YOU_KNOW: &[(&str, &str)] = &[
(
"Tab complète vos agents",
"dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.",
),
(
"!! relance la dernière commande",
"l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.",
),
(
"Le shell am double le shell système",
"tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.",
),
(
"--dry-run simule sans rien changer",
"testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.",
),
(
"Les groupes lancent plusieurs agents",
"'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.",
),
(
"Cherchez dans toute l'aide",
"'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.",
),
(
"--json pour les scripts",
"quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.",
),
(
"Les alias raccourcissent tout",
"'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.",
),
(
"export/import sauvegardent votre monde",
"config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.",
),
(
"Les secrets vivent dans le keyring",
"'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.",
),
(
"Le thème suit votre humeur",
"'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.",
),
(
"doctor répare",
"'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).",
),
];
/// Entry point: print the cheat sheet page (or a single random tip).
pub fn run(app: &App, random: bool) -> Result<i32> {
print!("{}", render(app, random));
Ok(0)
}
/// Render the page as text (or JSON with --json).
pub fn render(app: &App, random: bool) -> String {
if app.json() {
return render_json(app, random);
}
let p = Painter {
theme: app.theme(),
color: app.color(),
};
if random {
let (title, body) = random_tip();
return format!(
"💡 {} — {}\n",
p.acc(crate::i18n::tr_in(app.lang(), title)),
crate::i18n::tr_in(app.lang(), body)
);
}
let mut out = String::new();
out.push_str(&header(app, &p));
out.push('\n');
for s in SECTIONS {
out.push_str(&section_rule(app, &p, s.icon, s.title));
out.push('\n');
for e in s.entries {
out.push_str(&entry(&p, app.lang(), e));
}
out.push('\n');
}
out.push_str(&most_used_section(app, &p));
out.push('\n');
let (title, body) = daily_tip();
out.push_str(&section_rule(app, &p, "💡", "Le saviez-vous ?"));
out.push('\n');
out.push_str(&format!(
" {} — {}\n",
p.acc(crate::i18n::tr_in(app.lang(), title)),
crate::i18n::tr_in(app.lang(), body)
));
out.push('\n');
out.push_str(&format!(
" {}\n",
p.dim(crate::i18n::tr_in(
app.lang(),
"am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'"
))
));
out
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
/// Width of the cheat sheet, following the terminal (capped).
fn page_width(app: &App) -> usize {
crate::output::terminal_width()
.unwrap_or(100)
.clamp(50, 110)
}
/// Tiny themed painter that honours the global --no-color flag.
struct Painter<'a> {
theme: &'a Theme,
color: bool,
}
impl Painter<'_> {
fn paint(&self, f: fn(&Theme, &str) -> String, s: &str) -> String {
if self.color {
f(self.theme, s)
} else {
s.to_string()
}
}
fn acc(&self, s: &str) -> String {
self.paint(Theme::acc, s)
}
fn inf(&self, s: &str) -> String {
self.paint(Theme::inf, s)
}
fn dim(&self, s: &str) -> String {
self.paint(Theme::dimmed, s)
}
fn hdr(&self, s: &str) -> String {
self.paint(Theme::hdr, s)
}
fn frame(&self, s: &str) -> String {
self.paint(Theme::frame, s)
}
fn val(&self, s: &str) -> String {
self.paint(Theme::val, s)
}
}
/// Title box: a framed header with a subtitle line.
fn header(app: &App, p: &Painter) -> String {
let w = page_width(app);
let title = " 🧠 TIP · votre antisèche de commandes am ";
let pad = w.saturating_sub(title.chars().count() + 2).max(1);
let top = format!("╭─{title}{}╮", "─".repeat(pad));
let sub = " les commandes les plus utiles · vos habitudes · une astuce du jour";
let subpad = w.saturating_sub(sub.chars().count() + 2).max(1);
let mid = format!("│{sub}{}│", " ".repeat(subpad));
let bot = format!("╰{}╯", "─".repeat(w.saturating_sub(2).max(2)));
format!(
"{}\n{}\n{}\n",
p.frame(&top),
p.dim(&mid),
p.frame(&bot)
)
}
/// Section separator: " 🚀 Lancer & piloter ────────".
fn section_rule(app: &App, p: &Painter, icon: &str, title: &str) -> String {
let w = page_width(app);
let label = format!(" {icon} {} ", crate::i18n::tr_in(app.lang(), title));
let dashes = "─".repeat(w.saturating_sub(label.chars().count()).max(1));
format!("{}{}", p.hdr(&label), p.frame(&dashes))
}
/// One cheat-sheet entry: usage line, options, example.
fn entry(p: &Painter, lang: crate::i18n::Lang, e: &TipEntry) -> String {
let mut out = String::new();
out.push_str(&format!(
" {} {} {}\n",
p.inf("▸"),
p.acc(e.usage),
crate::i18n::tr_in(lang, e.about)
));
for (flag, desc) in e.options {
out.push_str(&format!(
" {} {}\n",
p.dim(flag),
crate::i18n::tr_in(lang, desc)
));
}
if !e.example.is_empty() {
out.push_str(&format!(" {} {}\n", p.dim("❯"), p.val(e.example)));
}
out
}
/// Count the am commands of a history (first token per entry), most used
/// first, truncated to 8. Pure and testable.
pub fn count_commands(entries: &[HistoryEntry]) -> Vec<(String, usize)> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for e in entries {
if e.kind != "am" {
continue;
}
if let Some(first) = e.cmd.split_whitespace().next() {
if !first.is_empty() {
*counts.entry(first.to_string()).or_default() += 1;
}
}
}
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
v.truncate(8);
v
}
/// The user's most-used commands, from the REPL history.
fn most_used(app: &App) -> Vec<(String, usize)> {
count_commands(&crate::history::read_all(app, 0))
}
/// A 10-cell usage bar: ▰ for used cells, ▱ for the rest.
fn usage_bar(count: usize, max: usize) -> String {
if max == 0 {
return "▱".repeat(10);
}
let filled = (count * 10 + max / 2) / max;
format!(
"{}{}",
"▰".repeat(filled.min(10)),
"▱".repeat(10usize.saturating_sub(filled.min(10)))
)
}
/// "🔥 Vos commandes les plus utilisées": a little bar chart from the
/// REPL history, so the page grows with the user.
fn most_used_section(app: &App, p: &Painter) -> String {
let top = most_used(app);
let mut out = String::new();
out.push_str(&section_rule(app, p, "🔥", "Vos commandes les plus utilisées"));
out.push('\n');
if top.is_empty() {
out.push_str(&format!(
" {} — {}\n",
p.dim(crate::i18n::tr_in(app.lang(), "aucune commande enregistrée")),
crate::i18n::tr_in(
app.lang(),
"lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici"
)
));
return out;
}
let max = top[0].1;
let name_w = top.iter().map(|(n, _)| n.chars().count()).max().unwrap_or(0).min(14);
for (name, count) in &top {
out.push_str(&format!(
" {} {} {}×\n",
p.acc(&format!("{name:<name_w$}")),
p.dim(&usage_bar(*count, max)),
p.inf(&count.to_string()),
));
}
out
}
/// Tip of the day: deterministic pick that changes daily.
fn daily_tip() -> (&'static str, &'static str) {
let day = chrono::Local::now().ordinal() as usize;
DID_YOU_KNOW[day % DID_YOU_KNOW.len()]
}
/// Random tip for '--random'.
fn random_tip() -> (&'static str, &'static str) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as usize)
.unwrap_or(0);
DID_YOU_KNOW[nanos % DID_YOU_KNOW.len()]
}
/// The same content as structured JSON (--json).
fn render_json(app: &App, random: bool) -> String {
let (title, body) = if random { random_tip() } else { daily_tip() };
let sections: Vec<serde_json::Value> = SECTIONS
.iter()
.map(|s| {
json!({
"icon": s.icon,
"title": s.title,
"entries": s.entries.iter().map(|e| json!({
"usage": e.usage,
"about": e.about,
"options": e.options.iter().map(|(flag, desc)| json!({ "flag": flag, "desc": desc })).collect::<Vec<_>>(),
"example": e.example,
})).collect::<Vec<_>>(),
})
})
.collect();
let payload = json!({
"sections": sections,
"most_used": most_used(app).iter().map(|(command, count)| json!({ "command": command, "count": count })).collect::<Vec<_>>(),
"tip": { "title": title, "body": body },
});
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(cmd: &str, kind: &str) -> HistoryEntry {
HistoryEntry::now(cmd.to_string()).with_kind(kind.to_string())
}
#[test]
fn counts_only_am_commands_by_first_token() {
let entries = vec![
entry("start pi", "am"),
entry("start pi", "am"),
entry("list", "am"),
entry("git status", "shell"),
entry("start claude-code", "am"),
entry("", "am"),
];
let top = count_commands(&entries);
assert_eq!(top[0], ("start".to_string(), 3));
assert_eq!(top[1], ("list".to_string(), 1));
assert_eq!(top.len(), 2, "shell commands and empty lines are ignored");
}
#[test]
fn empty_history_yields_no_commands() {
assert!(count_commands(&[]).is_empty());
}
#[test]
fn usage_bars_fill_and_cap_at_ten_cells() {
assert_eq!(usage_bar(10, 10), "▰".repeat(10));
assert_eq!(usage_bar(0, 10), "▱".repeat(10));
assert_eq!(usage_bar(5, 10), "▰".repeat(5) + &"▱".repeat(5));
// A count larger than the max still fits the bar.
assert_eq!(usage_bar(99, 10).chars().count(), 10);
}
#[test]
fn daily_tip_is_always_in_bounds() {
let (t, b) = daily_tip();
assert!(DID_YOU_KNOW.iter().any(|(x, y)| *x == t && *y == b));
}
#[test]
fn sections_are_non_empty_and_consistent() {
for s in SECTIONS {
assert!(!s.entries.is_empty(), "section {} has no entries", s.title);
for e in s.entries {
assert!(!e.usage.is_empty());
assert!(!e.about.is_empty());
}
}
}
}
+105 -1
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists.
use super::*;
use crate::backup;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::{Runner, SystemRunner};
@@ -9,15 +10,45 @@ use anyhow::{bail, Result};
use std::cmp::Ordering;
use std::path::PathBuf;
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
pub fn run(
app: &App,
agent_name: Option<&str>,
all: bool,
rollback: Option<&str>,
) -> Result<i32> {
// Issue #64: --rollback restores a pre-update backup instead of updating.
if rollback.is_some() {
return rollback_point(app, rollback);
}
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
let policy = app.config.settings.update_policy.unwrap_or_default();
if policy == UpdatePolicy::None && all {
app.log.warn("update policy is 'none'; 'am update --all' is a no-op");
return Ok(0);
}
if let Some(name) = agent_name {
if policy == UpdatePolicy::None && !app.yes() {
app.log.warn("update policy is 'none'; use --yes to force a single-agent update");
}
return update_one(app, &runner, name);
}
if all {
let entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0;
// Issue #64: one snapshot covers the whole batch.
if !app.dry_run() && !names.is_empty() {
let info = backup::create_backup(app, "update:--all", &names)?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_reason("update:--all"),
);
}
for name in names {
if let Err(e) = update_one(app, &runner, &name) {
failed += 1;
@@ -31,6 +62,30 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
// Issue #65: honor per-agent pinned version.
if let Some(pin) = agent.pin_version.as_deref() {
let current = app
.state
.get(&agent.name)
.ok()
.flatten()
.and_then(|e| e.version.clone());
if current.as_deref() == Some(pin) {
app.log.success(&format!(
"{} is pinned to v{pin} and already at that version",
agent.title()
));
} else {
app.log.warn(&format!(
"{} is pinned to v{pin} but currently at {}; update skipped",
agent.title(),
current.as_deref().unwrap_or("unknown")
));
}
return Ok(0);
}
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
bail!(
"agent '{}' is not installed by agent-manager — use 'am install {}'",
@@ -90,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0);
}
// Issue #64: snapshot before the real update so a broken upgrade can be
// undone with 'am update --rollback'.
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_agent(agent.name.clone())
.with_reason("pre-update"),
);
crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
@@ -113,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
));
Ok(0)
}
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
/// '--rollback list' prints the available backup points.
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
if point == Some("list") {
let all = backup::list_backups(app);
if all.is_empty() {
app.log.info("no backup found — run an update first to create one");
return Ok(0);
}
println!("pre-update backups ({}):", all.len());
for b in &all {
println!(
" {} {} agents={}{}",
b.id,
b.reason,
if b.agents.is_empty() {
"all".to_string()
} else {
b.agents.join(",")
},
if b.state && b.config { " [state+config]" } else { "" }
);
}
println!("\nuse 'am update --rollback <id>' to restore one");
return Ok(0);
}
let info = backup::restore_backup(app, point)?;
app.emit(
&Event::now(EventKind::Rollback)
.with_args(vec![info.id.clone()])
.with_reason(&info.reason),
);
app.log.success(&format!(
"rolled back to backup {} ({})",
info.id, info.reason
));
Ok(0)
}
+7
View File
@@ -0,0 +1,7 @@
//! web: serve the local read-only dashboard (issue #54).
use super::*;
pub fn run(app: &App, port: Option<u16>, no_open: bool) -> Result<i32> {
crate::web::run(app, port.unwrap_or(crate::web::DEFAULT_PORT), !no_open)
}
+581 -8
View File
@@ -102,6 +102,252 @@ pub struct Settings {
/// lines (global; project profiles can add their own).
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
/// Update policy: how aggressively 'am update' should act (issue #65).
#[serde(default)]
pub update_policy: Option<UpdatePolicy>,
/// A model is a prune candidate after this many days without use
/// (issue #73, default 30).
#[serde(default)]
pub models_prune_days: Option<u64>,
/// Base URL of the official catalog used by 'am catalog update'
/// (issue #60; default: the embedded catalog on Gitea).
#[serde(default)]
pub catalog_url: Option<String>,
/// How long (seconds) a remote catalog stays cached before a refresh
/// is attempted (issue #60, default 3600).
#[serde(default)]
pub catalog_cache_ttl_secs: Option<u64>,
/// How many pre-update backups 'am update --rollback' keeps
/// (issue #64, default 5).
#[serde(default)]
pub backups_keep: Option<u32>,
/// Purge sessions finished more than N days ago (issue #53, default 90).
#[serde(default)]
pub sessions_retention_days: Option<u64>,
/// Token price models used to estimate session costs (issue #49).
#[serde(default)]
pub cost_models: Option<BTreeMap<String, CostModel>>,
/// CPU/memory thresholds for 'am monitor' alerts (issue #50).
#[serde(default)]
pub monitor_thresholds: Option<MonitorThresholds>,
/// Git repository where 'am sync' pushes the state (issue #66).
#[serde(default)]
pub sync_repo: Option<String>,
/// Push automatically when the REPL exits (issue #66, opt-in).
#[serde(default)]
pub sync_on_exit: Option<bool>,
/// Anonymous opt-in telemetry (issue #76): aggregated counters only.
/// Nothing is collected or sent while `enabled` is false (the default).
#[serde(default)]
pub telemetry: Option<TelemetrySettings>,
/// am ask (issue #78): natural language → am command. The local rules
/// are always available; the optional LLM refinement uses the provider
/// registry. `enabled: false` turns the whole command off.
#[serde(default)]
pub ask: Option<AskSettings>,
/// Community registry (issue #77): trusted catalog sources + the author
/// name recorded by `am registry publish`.
#[serde(default)]
pub registry: Option<RegistrySettings>,
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
/// Name of the provider used by default (issue #88) — the provider
/// whose default model is applied at install/run when none is given.
#[serde(default)]
pub default_provider: Option<String>,
/// LLM provider registry (issue #88): provider name -> base URL,
/// available models and default model. API tokens are NOT stored here —
/// they live in the OS keyring under `providers/<name>/api_key`
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
/// am ai security settings (issue #97): default safety policy and
/// extra risky command patterns for the Shell AI command.
#[serde(default)]
pub shell_ai: Option<ShellAiSettings>,
}
/// Anonymous opt-in telemetry (issue #76): aggregated counters only — never
/// paths, commands, agent names or identifiers. Disabled by default.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TelemetrySettings {
/// Explicit opt-in: nothing is collected or sent while false.
pub enabled: bool,
/// Batch endpoint (e.g. https://am-telemetry.example/v1/ping). When
/// unset, counters stay local even when enabled.
pub endpoint: Option<String>,
}
/// am ask settings (issue #78): the LLM refinement is optional — without a
/// provider, the local rule-based translator still works, offline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AskSettings {
/// Master switch; false disables `am ask` entirely (issue #78).
pub enabled: bool,
/// Provider of the registry used for the refinement call. Falls back
/// to settings.default_provider when unset. None = rules only.
pub provider: Option<String>,
/// Model for the refinement call. Falls back to the provider default.
pub model: Option<String>,
}
impl Default for AskSettings {
fn default() -> Self {
AskSettings {
enabled: true,
provider: None,
model: None,
}
}
}
/// am ai security settings (issue #97): safety policy and risky patterns
/// for the Shell AI command. The default policy is `dry-run` — nothing is
/// executed without an explicit confirmation.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ShellAiSettings {
/// Default safety policy of `am ai --exec`:
/// `dry-run` (show only), `confirm` (ask for risky commands) or
/// `auto` (execute everything). Default: dry-run.
pub default_safety: Option<String>,
/// Extra command substrings classified as risky (in addition to the
/// built-in patterns: rm -rf, dd if, mkfs, chmod -R 777, ...).
#[serde(default)]
pub risky_patterns: Vec<String>,
}
impl ShellAiSettings {
/// The effective default safety mode (unknown values fall back to
/// dry-run — the safe choice).
pub fn safety_mode(&self) -> crate::shell_ai::SafetyMode {
match self.default_safety.as_deref() {
Some("auto") => crate::shell_ai::SafetyMode::Auto,
Some("confirm") => crate::shell_ai::SafetyMode::Confirm,
_ => crate::shell_ai::SafetyMode::DryRun,
}
}
}
/// Community registry settings (issue #77).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RegistrySettings {
/// Trusted catalog sources: their manifests are validated (checksum)
/// and their agents searchable through `am registry search`.
#[serde(default)]
pub sources: Vec<String>,
/// Author name recorded by `am registry publish` (default: "unknown").
pub author: Option<String>,
}
/// Sandbox profile of an agent (issue #79): restrict which commands it may
/// run, which working directories it may use, and whether it may reach the
/// network. Enforcement is best-effort per platform — the launcher checks
/// the resolved command and the process cwd, and a warning is emitted when
/// the enforcement cannot be fully guaranteed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SandboxProfile {
/// Sandbox active for this agent (default false).
pub enabled: bool,
/// Allowed command names (basenames). Empty = no command restriction.
#[serde(default)]
pub commands: Vec<String>,
/// Allowed working directories (support ~ and env vars). Empty = no
/// directory restriction.
#[serde(default)]
pub dirs: Vec<String>,
/// Network access (default true). false = best-effort block.
#[serde(default = "default_true")]
pub network: bool,
}
/// One entry of the LLM provider registry (issue #88).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProviderDef {
/// Base URL of the provider API (e.g. https://api.openai.com/v1).
pub base_url: String,
/// Model used by default for this provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
/// Models offered by this provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// Plugin scripts settings (issue #75): how long one plugin may run and
/// which plugins of the directory are enabled (empty = every plugin).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PluginSettings {
/// Default timeout in seconds for every plugin (default 10).
#[serde(default)]
pub timeout_secs: Option<u64>,
/// Only run these plugin names (empty = all the plugins of the dir).
#[serde(default)]
pub enabled: Option<Vec<String>>,
}
/// Token price model: USD per million tokens (issue #49).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostModel {
pub input_per_mtok: f64,
pub output_per_mtok: f64,
}
/// Default price sheet (USD / MTok) used when settings.cost_models is absent.
pub fn default_cost_models() -> BTreeMap<String, CostModel> {
let mut m = BTreeMap::new();
m.insert("claude-sonnet-4".into(), CostModel { input_per_mtok: 3.0, output_per_mtok: 15.0 });
m.insert("claude-opus-4".into(), CostModel { input_per_mtok: 15.0, output_per_mtok: 75.0 });
m.insert("claude-haiku-3.5".into(), CostModel { input_per_mtok: 0.8, output_per_mtok: 4.0 });
m.insert("gpt-4o".into(), CostModel { input_per_mtok: 2.5, output_per_mtok: 10.0 });
m.insert("gpt-4o-mini".into(), CostModel { input_per_mtok: 0.15, output_per_mtok: 0.6 });
m.insert("deepseek-chat".into(), CostModel { input_per_mtok: 0.27, output_per_mtok: 1.10 });
m.insert("llama-3.1-8b".into(), CostModel { input_per_mtok: 0.05, output_per_mtok: 0.05 });
m
}
/// Alert thresholds for 'am monitor' (issue #50).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MonitorThresholds {
/// Alert when a managed process exceeds this CPU % (0 = disabled).
#[serde(default)]
pub cpu_pct: Option<f64>,
/// Alert when a managed process exceeds this RSS in MB (0 = disabled).
#[serde(default)]
pub mem_mb: Option<u64>,
}
/// Policy controlling how aggressively 'am update' upgrades installed agents.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UpdatePolicy {
/// Never update automatically; 'am update --all' is a no-op.
None,
/// Default: update only when explicitly requested by name or with --all.
#[default]
Manual,
/// Update only to the next patch/minor that is known safe (future use).
Safe,
/// Always update to the latest available version.
Latest,
}
impl UpdatePolicy {
pub fn as_str(&self) -> &'static str {
match self {
UpdatePolicy::None => "none",
UpdatePolicy::Manual => "manual",
UpdatePolicy::Safe => "safe",
UpdatePolicy::Latest => "latest",
}
}
}
/// A project profile: everything am needs to know about one project.
@@ -141,6 +387,27 @@ impl Settings {
self.stop_timeout_secs
}
}
/// Look up one provider of the registry (issue #88).
pub fn provider(&self, name: &str) -> Option<&ProviderDef> {
self.providers
.as_ref()
.and_then(|m| m.get(name))
.and_then(|p| p.as_ref())
}
/// Name of the default provider (issue #88).
pub fn default_provider_name(&self) -> Option<&str> {
self.default_provider.as_deref()
}
/// The default provider entry, when both the name and the definition
/// exist (issue #88).
pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> {
let name = self.default_provider.as_ref()?;
let def = self.provider(name)?;
Some((name, def))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -163,6 +430,13 @@ pub struct AgentDef {
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
#[serde(default)]
pub run: Option<String>,
/// Binary name probed on the PATH for external detection. When unset,
/// the first token of `run` is used — which is wrong for interpreter
/// launchers ("npx pkg", "python -m pkg"): the interpreter itself would
/// be matched, reporting the agent as present whenever node/python is
/// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai").
#[serde(default)]
pub detect: Option<String>,
/// Default arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
@@ -172,6 +446,38 @@ pub struct AgentDef {
/// Known version (otherwise detected via --version).
#[serde(default)]
pub version: Option<String>,
/// Pinned version: 'am update' will refuse to install a different one (issue #65).
#[serde(default)]
pub pin_version: Option<String>,
/// Environment variable carrying the model name at launch
/// (issue #71, e.g. ANTHROPIC_MODEL for claude-code).
#[serde(default)]
pub model_env: Option<String>,
/// CLI flag carrying the model name at launch (issue #71, e.g. "--model").
#[serde(default)]
pub model_arg: Option<String>,
/// Preferred provider of this agent (issue #90, e.g. "anthropic" for
/// claude-code). Falls back to settings.default_provider when unset.
#[serde(default)]
pub provider: Option<String>,
/// Preferred model for this agent (issue #90). Falls back to the
/// provider's default_model when unset.
#[serde(default)]
pub model: Option<String>,
/// Post-install provider configuration (issue #91).
#[serde(default)]
pub config: Option<AgentConfig>,
/// Manual setup commands shown after install when the agent manages its
/// own configuration (onboard/model/auth...). Placeholders {model},
/// {provider} and {base_url} are substituted with the resolved values
/// (v1.1.4).
#[serde(default)]
pub setup_hints: Vec<String>,
/// Sandbox profile (issue #79): allowed commands, working directories
/// and network policy. Disabled by default — the unsandboxed mode
/// stays available and documented.
#[serde(default)]
pub sandbox: Option<SandboxProfile>,
#[serde(default)]
pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally.
@@ -186,6 +492,50 @@ pub struct AgentDef {
/// Restrict the agent to platforms: linux, macos, windows (empty = all).
#[serde(default)]
pub platforms: Vec<String>,
/// Health check waited before the next orchestration step (issue #57).
#[serde(default)]
pub healthcheck: Option<HealthCheck>,
/// Container profile used by 'am run --container' (issue #58).
#[serde(default)]
pub container: Option<ContainerSpec>,
/// Price model key (settings.cost_models) used to estimate session costs
/// (issue #49; defaults to the first model of the price sheet).
#[serde(default)]
pub cost_model: Option<String>,
}
/// Health check for group orchestration (issue #57): a command that must
/// exit 0, or a TCP port that must accept connections, within timeout_secs.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HealthCheck {
/// Shell command to probe (exit 0 = healthy).
#[serde(default)]
pub command: Option<String>,
/// TCP port to connect to (accepts = healthy).
#[serde(default)]
pub port: Option<u16>,
/// Seconds to wait before declaring failure (default 30).
#[serde(default)]
pub timeout_secs: Option<u64>,
}
/// Container profile (issue #58): image, volumes, network and env applied
/// by 'am run <agent> --container'.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ContainerSpec {
/// Container image (required).
pub image: String,
/// Extra volumes "host:container[:ro]" on top of state_dir/log_dir.
#[serde(default)]
pub volumes: Vec<String>,
/// Network (default: host on Linux, bridge elsewhere).
#[serde(default)]
pub network: Option<String>,
/// Extra environment variables inside the container.
#[serde(default)]
pub env: BTreeMap<String, String>,
}
fn default_true() -> bool {
@@ -211,6 +561,12 @@ impl AgentDef {
})
}
/// Binary probed on the PATH for external detection: the explicit
/// `detect` name when set, otherwise the first token of `run`.
pub fn detect_token(&self) -> Option<String> {
self.detect.clone().or_else(|| self.first_token())
}
/// The run command split into tokens (empty when unset).
pub fn run_tokens(&self) -> Vec<String> {
self.run
@@ -385,6 +741,10 @@ pub struct InstallSpec {
/// Commands executed after a successful install (PATH includes the bin dir).
#[serde(default)]
pub post_install: Vec<String>,
/// The agent accepts a post-install provider configuration (issue #91).
/// False for agents without any API (issue #90).
#[serde(default = "default_true")]
pub configurable: bool,
}
impl InstallSpec {
@@ -398,6 +758,33 @@ impl InstallSpec {
}
}
/// Post-install provider configuration of an agent (issue #91): the env vars
/// the agent expects and the config files to write or softly edit. The
/// api_key slot is always injected as the `@secret` reference (issue #89) —
/// a token is never written in clear.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AgentConfig {
/// Expected env vars, keyed by semantic slot (api_key / model /
/// base_url / provider) -> the agent's variable name.
pub env_map: BTreeMap<String, String>,
/// Configuration files to write or softly edit after the install.
pub files: Vec<AgentConfigFile>,
/// Provider of this agent, otherwise settings.default_provider.
pub provider_default: Option<String>,
}
/// One config file of an agent (issue #91).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentConfigFile {
/// File path (supports ~ and env vars).
pub path: String,
/// Keys to write among: api_key, model, base_url, provider.
#[serde(default)]
pub keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
@@ -481,7 +868,10 @@ pub fn parse_file(path: &Path) -> Result<Config> {
fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canon.clone()) {
return Err(anyhow!("config include cycle detected at {}", path.display()));
return Err(anyhow!(
"config include cycle detected at {}",
path.display()
));
}
let mut cfg = parse_file(path)?;
let includes = cfg.include.clone();
@@ -491,19 +881,55 @@ fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Resul
let mut merged = Config::default();
merged.version = cfg.version.clone();
for inc in &includes {
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
let sub = load_file_with_includes(&abs, visited)?;
let sub = load_include(inc, base_dir, visited)?;
merge(&mut merged, sub);
}
merge(&mut merged, cfg);
Ok(merged)
}
/// Load one include entry: a local path (relative to the including file) or
/// a remote catalog URL (http/https/file, cached on disk, issue #67).
fn load_include(inc: &str, base_dir: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let lower = inc.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let state_dir = crate::app::state_dir();
let text = crate::catalog_remote::fetch_for_config(&state_dir, inc)?;
let sub = crate::catalog_remote::parse_catalog(&text)
.with_context(|| format!("remote include {inc} is not a valid catalog"))?;
// Remote includes can themselves reference other URLs; local paths
// inside them are resolved against the config directory.
let mut merged = Config::default();
merged.version = sub.version.clone();
for sub_inc in &sub.include {
let nested = load_include(sub_inc, base_dir, visited)?;
merge(&mut merged, nested);
}
let mut own = sub;
own.include.clear();
merge(&mut merged, own);
return Ok(merged);
}
if lower.starts_with("file://") {
let local = inc.strip_prefix("file://").unwrap_or(inc);
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
let local = if cfg!(windows) && local.starts_with('/') {
local.strip_prefix('/').unwrap_or(local)
} else {
local
};
let abs = PathBuf::from(local);
return load_file_with_includes(&abs, visited);
}
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
load_file_with_includes(&abs, visited)
}
/// Merge an overlay config into a base config: user definitions replace
/// default agents with the same name; maps are merged key by key.
pub fn merge(base: &mut Config, overlay: Config) {
@@ -533,6 +959,78 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.self_update_base_url.is_some() {
s.self_update_base_url = o.self_update_base_url;
}
if o.theme.is_some() {
s.theme = o.theme;
}
if o.update_policy.is_some() {
s.update_policy = o.update_policy;
}
if o.models_prune_days.is_some() {
s.models_prune_days = o.models_prune_days;
}
if o.catalog_url.is_some() {
s.catalog_url = o.catalog_url;
}
if o.catalog_cache_ttl_secs.is_some() {
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs;
}
if o.backups_keep.is_some() {
s.backups_keep = o.backups_keep;
}
if o.sessions_retention_days.is_some() {
s.sessions_retention_days = o.sessions_retention_days;
}
if o.cost_models.is_some() {
s.cost_models = o.cost_models;
}
if o.monitor_thresholds.is_some() {
s.monitor_thresholds = o.monitor_thresholds;
}
if o.sync_repo.is_some() {
s.sync_repo = o.sync_repo;
}
if o.sync_on_exit.is_some() {
s.sync_on_exit = o.sync_on_exit;
}
if o.telemetry.is_some() {
s.telemetry = o.telemetry;
}
if o.ask.is_some() {
s.ask = o.ask;
}
if o.registry.is_some() {
s.registry = o.registry;
}
if o.plugins.is_some() {
s.plugins = o.plugins;
}
if o.default_provider.is_some() {
s.default_provider = o.default_provider;
}
if o.shell_ai.is_some() {
s.shell_ai = o.shell_ai;
}
// Providers merge key by key (issue #88): an overlay adds or replaces
// one provider without wiping the others. An explicit `null` in the
// overlay DELETES the provider (the standard YAML overlay pattern) so a
// provider shipped in the default catalog can be removed by the user.
if let Some(overlay_providers) = o.providers {
match s.providers.as_mut() {
Some(map) => {
for (k, v) in overlay_providers {
match v {
Some(def) => {
map.insert(k, Some(def));
}
None => {
map.remove(&k);
}
}
}
}
None => s.providers = Some(overlay_providers),
}
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
@@ -598,6 +1096,32 @@ pub fn validate(config: &Config) -> Vec<String> {
problems.push(format!("agent '{}': dependency without a name", agent.name));
}
}
if let Some(d) = &config.settings.default_provider {
if config.settings.provider(d).is_none() {
problems.push(format!(
"settings.default_provider '{d}' is not defined in settings.providers"
));
}
}
if let Some(providers) = &config.settings.providers {
for (name, p) in providers {
// A null entry is a deletion marker (issue #90): skip it.
let Some(p) = p else {
continue;
};
if p.base_url.trim().is_empty() {
problems.push(format!("provider '{name}': 'base_url' is required"));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
problems.push(format!(
"provider name '{name}' must be a lowercase slug ([a-z0-9_-])"
));
}
}
}
}
problems
}
@@ -697,6 +1221,31 @@ mod tests {
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
}
#[test]
fn detect_token_prefers_explicit_binary() {
// Interpreter launcher: without `detect`, the interpreter itself
// would be probed (false "external" when node/python is on PATH).
let a: AgentDef = serde_yaml::from_str(
"name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n",
)
.unwrap();
assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai"));
// No `detect`: falls back to the first token of run.
let b: AgentDef =
serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap();
assert_eq!(b.detect_token().as_deref(), Some("python"));
assert_eq!(b.first_token().as_deref(), Some("python"));
// Plain binary command is unchanged.
let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap();
assert_eq!(c.detect_token().as_deref(), Some("claude"));
// The embedded catalog must stay valid with the new field.
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap();
assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai"));
let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap();
assert_eq!(nano.detect_token().as_deref(), Some("nanobot"));
}
#[test]
fn expands_paths() {
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
@@ -764,6 +1313,30 @@ agents:
assert!(problems.iter().any(|p| p.contains("'run' is required")));
}
#[test]
fn merge_keeps_the_user_theme_setting() {
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let overlay: Config = serde_yaml::from_str(
r#"
version: "1.0"
settings:
theme: ocean
agents: []
"#,
)
.unwrap();
merge(&mut base, overlay);
assert_eq!(base.settings.theme.as_deref(), Some("ocean"));
// The embedded default catalog defines no theme: an overlay without
// one leaves the merged setting untouched.
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let overlay: Config = serde_yaml::from_str("version: \"1.0\"
agents: []
").unwrap();
merge(&mut base, overlay);
assert_eq!(base.settings.theme, None);
}
#[test]
fn one_or_many_parses_both() {
let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap();
+323
View File
@@ -0,0 +1,323 @@
//! Cost tracking (issue #49): collect token usage exposed by agents, price
//! it against the configured model sheet, and aggregate per agent/session.
//! No runtime dependency is added — everything is std + the existing config.
use crate::config::{self, CostModel};
use crate::events::{self, Event, EventKind};
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Token usage collected from an agent.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
pub struct Usage {
pub tokens_in: u64,
pub tokens_out: u64,
}
/// One aggregated cost row (per agent, or global totals).
#[derive(Debug, Clone, Default, Serialize)]
pub struct CostStats {
pub agent: String,
pub sessions: u64,
pub tokens_in: u64,
pub tokens_out: u64,
pub cost_usd: f64,
}
/// Price the usage against a model of the sheet (issue #49).
/// Unknown models fall back to the first model of the sheet.
pub fn estimate_cost(
model: Option<&str>,
usage: Usage,
sheet: &BTreeMap<String, CostModel>,
) -> f64 {
let m = model
.and_then(|k| sheet.get(k))
.or_else(|| sheet.values().next());
match m {
Some(cm) => {
usage.tokens_in as f64 * cm.input_per_mtok / 1_000_000.0
+ usage.tokens_out as f64 * cm.output_per_mtok / 1_000_000.0
}
None => 0.0,
}
}
/// Collect the usage an agent exposed for its latest session (issue #49).
///
/// Supported sources today:
/// - Claude Code: `~/.claude/projects/*/<session>.jsonl` — the last JSON
/// line carrying an `usage` object with `input_tokens`/`output_tokens`.
///
/// Returns None when the agent exposes nothing (safe, silent).
pub fn collect_usage(agent: &str, cwd: Option<&str>) -> Option<Usage> {
match agent {
"claude-code" => claude_usage(cwd),
_ => None,
}
}
/// Claude Code stores one JSONL per session under ~/.claude/projects/<enc>/.
/// The final line with a "usage" object holds the cumulative token counts.
fn claude_usage(cwd: Option<&str>) -> Option<Usage> {
let root = config::home_dir()?.join(".claude/projects");
let mut candidates: Vec<(String, PathBuf)> = Vec::new();
for entry in std::fs::read_dir(&root).ok()?.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
for f in std::fs::read_dir(&dir).ok()?.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) == Some("jsonl") {
if let Ok(mtime) = f.metadata().and_then(|m| m.modified()) {
candidates.push((mtime_key(mtime), p));
}
}
}
}
candidates.sort();
let last = candidates.last()?.1.clone();
parse_claude_file(&last, cwd)
}
fn mtime_key(t: std::time::SystemTime) -> String {
format!(
"{:?}",
t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default()
)
}
/// Parse a Claude Code session file: the last line whose "usage" object has
/// input/output token counts. Filters by cwd when given (the file embeds it
/// as `cwd` on summary lines).
fn parse_claude_file(path: &std::path::Path, cwd: Option<&str>) -> Option<Usage> {
let text = std::fs::read_to_string(path).ok()?;
let mut usage = None;
for line in text.lines() {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("usage").is_none() {
continue;
}
if let Some(want) = cwd {
if v.get("cwd").and_then(|c| c.as_str()) != Some(want) {
continue;
}
}
let u = v.get("usage")?;
let tin = u.get("input_tokens").and_then(|n| n.as_u64())?;
let tout = u.get("output_tokens").and_then(|n| n.as_u64())?;
usage = Some(Usage {
tokens_in: tin,
tokens_out: tout,
});
}
usage
}
/// Record a session's usage: persist it on the session record and emit a
/// `cost` event (axe 8 journal). Called when an agent session finishes.
pub fn record_session_usage(
app: &crate::app::App,
agent: &str,
session_id: &str,
cwd: Option<&str>,
) -> Result<()> {
let Some(usage) = collect_usage(agent, cwd) else {
return Ok(());
};
let model = app
.catalog
.resolve(agent)
.and_then(|a| a.cost_model.as_deref());
let sheet = app
.config
.settings
.cost_models
.clone()
.unwrap_or_else(config::default_cost_models);
let cost = estimate_cost(model, usage, &sheet);
// Persist on the session record.
let mut sf = crate::sessions::load(app)?;
if let Some(r) = sf.sessions.get_mut(session_id) {
r.tokens_in = Some(usage.tokens_in);
r.tokens_out = Some(usage.tokens_out);
r.cost_usd = Some(cost);
crate::sessions::save(app, &sf)?;
}
// Journal a dedicated event (stable --json contract, additive fields).
app.emit(
&Event::now(EventKind::Cost)
.with_agent(agent.to_string())
.with_session(session_id)
.with_usage(usage.tokens_in, usage.tokens_out)
.with_cost(cost),
);
Ok(())
}
/// Aggregate cost events into per-agent rows (issue #49). `--json` output
/// stays stable: agent, sessions, tokens_in, tokens_out, cost_usd.
pub fn aggregate(
app: &crate::app::App,
period: Option<&str>,
) -> Result<Vec<CostStats>> {
let cut = crate::commands::stats_cmd::cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, CostStats> = BTreeMap::new();
for ev in events {
if ev.kind != EventKind::Cost {
continue;
}
if let Some(cut) = &cut {
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) {
if ts.with_timezone(&chrono::Utc) < *cut {
continue;
}
}
}
let agent = ev.agent.clone().unwrap_or_else(|| "unknown".to_string());
let row = map.entry(agent.clone()).or_default();
row.agent = agent;
row.sessions += 1;
row.tokens_in += ev.tokens_in.unwrap_or(0);
row.tokens_out += ev.tokens_out.unwrap_or(0);
row.cost_usd += ev.cost_usd.unwrap_or(0.0);
}
Ok(map.into_values().collect())
}
/// Total across every row (used by the weekly/monthly report).
pub fn totals(rows: &[CostStats]) -> CostStats {
let mut t = CostStats {
agent: "total".to_string(),
..Default::default()
};
for r in rows {
t.sessions += r.sessions;
t.tokens_in += r.tokens_in;
t.tokens_out += r.tokens_out;
t.cost_usd += r.cost_usd;
}
t
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estimate_cost_uses_model_prices() {
let sheet = config::default_cost_models();
// 1M input @ $3 + 500k output @ $15 = 3 + 7.5 = $10.5
let u = Usage {
tokens_in: 1_000_000,
tokens_out: 500_000,
};
let c = estimate_cost(Some("claude-sonnet-4"), u, &sheet);
assert!((c - 10.5).abs() < 1e-9, "got {c}");
}
#[test]
fn estimate_cost_falls_back_to_first_model() {
let sheet = config::default_cost_models();
let c = estimate_cost(Some("nope-model"), Usage { tokens_in: 0, tokens_out: 0 }, &sheet);
assert_eq!(c, 0.0);
}
#[test]
fn estimate_cost_unknown_sheet_is_zero() {
let sheet = BTreeMap::new();
let c = estimate_cost(None, Usage { tokens_in: 1000, tokens_out: 1000 }, &sheet);
assert_eq!(c, 0.0);
}
#[test]
fn parse_claude_file_extracts_last_usage() {
let dir = std::env::temp_dir().join(format!("am-costs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("session.jsonl");
std::fs::write(
&f,
concat!(
"{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5}},\"cwd\":\"/x\"}\n",
"{\"type\":\"summary\",\"usage\":{\"input_tokens\":100,\"output_tokens\":40},\"cwd\":\"/x\"}\n",
),
)
.unwrap();
let u = parse_claude_file(&f, Some("/x")).unwrap();
assert_eq!(u.tokens_in, 100);
assert_eq!(u.tokens_out, 40);
// Wrong cwd → filtered out.
assert!(parse_claude_file(&f, Some("/other")).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn aggregate_sums_per_agent() {
let app = test_app("costs-agg");
app.emit(
&Event::now(EventKind::Cost)
.with_agent("a".to_string())
.with_usage(10, 5)
.with_cost(0.5),
);
app.emit(
&Event::now(EventKind::Cost)
.with_agent("a".to_string())
.with_usage(20, 10)
.with_cost(1.0),
);
app.emit(
&Event::now(EventKind::Cost)
.with_agent("b".to_string())
.with_usage(100, 50)
.with_cost(2.0),
);
let rows = aggregate(&app, None).unwrap();
assert_eq!(rows.len(), 2);
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.sessions, 2);
assert_eq!(a.tokens_in, 30);
assert_eq!(a.tokens_out, 15);
assert!((a.cost_usd - 1.5).abs() < 1e-9);
let t = totals(&rows);
assert!((t.cost_usd - 3.5).abs() < 1e-9);
assert_eq!(t.sessions, 3);
}
fn test_app(tag: &str) -> crate::app::App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
use clap::Parser;
let cli = crate::cli::Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"list",
]);
let mut app = crate::app::App::from_cli(cli).expect("app should build");
app.paths.state_file = dir.path().join(format!("state-{tag}.json"));
app.paths.log_dir = dir.path().join("logs");
app
}
}

Some files were not shown because too many files have changed in this diff Show More