Compare commits

..
26 Commits
Author SHA1 Message Date
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
58 changed files with 7832 additions and 179 deletions
+49 -51
View File
@@ -1,28 +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,
# ~300 Mo) 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 ~1 Go libres sur / pour tirer l'image (rust:1.94-alpine) ;
# - 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 ;
# - le job linux utilise cargo-zigbuild : zig (~50 Mo) fournit le linker
# croise pour les DEUX cibles musl statiques dans une seule image legere
# (rust:1.94-alpine n'embarque pas de compilateur croise aarch64).
# - zig (~50 Mo) fournit le linker croise pour Linux.
name: release
on:
@@ -35,41 +37,56 @@ concurrency:
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:alpine : sans nodejs le job echoue des le checkout.
# l'image rust:bookworm par defaut : sans nodejs le job echoue des le checkout.
- name: Prepare container (node for checkout action)
run: apk add --no-cache nodejs
run: apt-get update && apt-get install -y nodejs
- uses: actions/checkout@v4
- name: Install build tools (zig + cargo-zigbuild)
- name: Install build tools (zig + cargo-zigbuild + mingw)
run: |
apk add --no-cache build-base zip curl
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
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: 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 windows x86_64 (mingw)
run: cargo build --release --locked --target x86_64-pc-windows-gnu
- name: Stage archives
run: |
mkdir -p dist
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-linux-*.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
@@ -80,37 +97,16 @@ jobs:
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
env:
CARGO_TARGET_DIR: C:/am-target
CARGO_INCREMENTAL: "0"
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 C:/am-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/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
@@ -132,7 +128,7 @@ jobs:
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
@@ -140,6 +136,8 @@ jobs:
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
+98 -2
View File
@@ -15,6 +15,7 @@
- 🚀 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é.
@@ -35,6 +36,7 @@ flowchart TB
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"]
@@ -62,7 +64,9 @@ flowchart TB
CLI --> APP
REPL --> APP
TUI --> APP
WEB --> APP
APP --> CLI_DEF
APP --> SHELL_AI
APP --> CONFIG
APP --> CATALOG
APP --> STATE
@@ -76,6 +80,7 @@ flowchart TB
CMD --> DASH
CMD --> STATS
CMD --> SESSIONS
SHELL_AI --> CMD
```
---
@@ -131,6 +136,7 @@ src/
├── 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
@@ -149,15 +155,34 @@ src/
├── 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/ 📦 35 modules, un par commande
└── installers/ 📦 9 installateurs spécialisés
├── commands/ 📦 51 modules, un par commande
└── installers/ 📦 8 installateurs spécialisés
```
---
@@ -435,6 +460,26 @@ flowchart TB
| `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
@@ -673,6 +718,57 @@ flowchart LR
---
## ⚡ 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
Generated
+182 -11
View File
@@ -21,9 +21,10 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.6.0"
version = "1.1.1"
dependencies = [
"anyhow",
"base64",
"chrono",
"clap",
"clap_complete",
@@ -35,15 +36,18 @@ dependencies = [
"nu-ansi-term",
"ratatui",
"reedline",
"rpassword",
"semver",
"serde",
"serde_json",
"serde_yaml",
"sha1",
"sha2",
"shell-words",
"tar",
"tempfile",
"tiny_http",
"toml",
"ureq",
"wait-timeout",
"which",
@@ -1101,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",
]
@@ -1138,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"
@@ -1763,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"
@@ -1887,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"
@@ -2301,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"
@@ -2693,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]]
@@ -2702,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]]
@@ -2720,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]]
@@ -2736,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"
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.6.0"
version = "1.1.1"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -32,9 +32,13 @@ 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"
+77 -3
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… **77 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 77 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
@@ -78,11 +104,16 @@ plateforme, il compile automatiquement depuis les sources.
| 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 |
@@ -113,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é (77 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 |
@@ -165,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 |
@@ -309,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é (77 agents)
Vos définitions **complètent ou surchargent** le catalogue par nom.
+198 -10
View File
@@ -1,6 +1,7 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.2** · Statut : propositions, non engagées
> **Version du document : 3.0** · Statut : phases 0 à 3 livrées
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.0
> 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 +14,62 @@
> 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`.
---
@@ -34,7 +91,7 @@
## 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 +153,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 |
@@ -328,6 +385,110 @@ 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 |
---
## 8. 🚀 Jalons versionnés
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
@@ -467,16 +628,43 @@ 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).*
+158
View File
@@ -43,6 +43,71 @@ settings:
# 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]
# --- Command aliases ----------------------------------------------------------
aliases:
@@ -54,6 +119,7 @@ aliases:
gjc: gajae-code
tiny: tiny-agents
continue: continue-cli
ai: aichat
# --- Groups (start/stop/restart several agents together) ----------------------
groups:
@@ -78,6 +144,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
@@ -91,6 +163,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
@@ -231,6 +304,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
@@ -481,6 +559,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
@@ -723,6 +810,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]
@@ -795,6 +885,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"
@@ -820,6 +975,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
+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
+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
+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)
+7 -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\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\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
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
\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
+7 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-container\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
@@ -12,6 +12,12 @@ Run the agent command directly with the given arguments (no process management)
\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
+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
+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
+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
+7 -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\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\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
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
\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
+20 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.6.0"
.TH am 1 "am 1.1.0"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -118,12 +118,27 @@ Schedule am commands (issue #56)
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
@@ -148,6 +163,9 @@ Real\-time monitor of the managed processes (issue #50)
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
@@ -232,4 +250,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.6.0
v1.1.0
+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)
}
+518
View File
@@ -0,0 +1,518 @@
//! 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);
}
}
/// 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",
));
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",
));
}
return Ok(());
}
let Some((pname, model)) = resolved else {
app.log.warn(crate::i18n::tr(
"aucun provider résolu — fichiers de config non écrits",
));
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),
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"));
}
}
+2
View File
@@ -155,6 +155,8 @@ impl App {
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);
}
+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);
}
}
+208 -5
View File
@@ -189,15 +189,40 @@ pub enum Command {
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)
@@ -275,6 +300,8 @@ pub enum Command {
},
/// 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")
@@ -357,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 {
@@ -484,6 +520,12 @@ pub enum Command {
/// 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,
@@ -555,6 +597,12 @@ pub struct StartArgs {
/// 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,
@@ -600,6 +648,48 @@ pub struct LabArgs {
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 {
@@ -619,6 +709,56 @@ pub struct WebArgs {
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 {
@@ -634,6 +774,63 @@ pub enum CatalogCmd {
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).
#[derive(Subcommand, Debug, Clone)]
pub enum ProfileCmd {
@@ -696,13 +893,16 @@ pub enum ScheduleCmd {
}
#[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,
@@ -711,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,
+244
View File
@@ -0,0 +1,244 @@
//! 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 in dry-run, 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>"`).
let files: Vec<String> = if args.files.is_empty() {
vec![".".to_string()]
} 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()
}
/// Validate the --role flag: the file must exist among the generated
/// am-* roles (v1.1.0 F3). Returns the aichat --agent arguments.
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
let Some(role) = role else {
return Ok(Vec::new());
};
let dir = crate::roles::aichat_agents_dir();
let path = dir.join(format!("{role}.md"));
if !path.exists() {
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));
}
Ok(vec!["--agent".to_string(), role.to_string()])
}
/// 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"
));
}
let role = role_args(app, args.role.as_deref())?;
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);
}
}
+13 -6
View File
@@ -114,14 +114,21 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
/// 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 = match &app.config_origin {
Some(p) => p.clone(),
None => user_config_path(app)?,
};
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)
@@ -155,7 +162,7 @@ fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
}
/// Reload a written config file to make sure the change stays valid.
fn reload_and_validate(path: &Path) -> Result<()> {
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:#}")
@@ -183,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
+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))))
}
+49 -5
View File
@@ -1,6 +1,7 @@
//! Command implementations and dispatch.
pub mod agents_cmd;
pub mod ai_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
@@ -26,13 +27,15 @@ 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 service_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 status_cmd;
@@ -40,6 +43,7 @@ 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;
@@ -98,8 +102,19 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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,
@@ -119,6 +134,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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())
@@ -167,7 +183,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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, rollback } => {
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
@@ -213,8 +240,16 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Doctor { fix, watch } => {
doctor_cmd::run(app, *fix, *watch)
}
Command::Run { agent, model, container, args } => {
run_cmd::run(app, agent, model.as_deref(), *container, args)
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 } => {
@@ -304,9 +339,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);
}
+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)
}
}
}
+285 -10
View File
@@ -71,13 +71,30 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
)?;
// --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(m) = opts.model.as_deref() {
if let Some(p) = opts.provider.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_model(app, agent, m)?;
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);
@@ -88,7 +105,7 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
// 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)?;
@@ -97,7 +114,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
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(
@@ -107,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)?;
@@ -116,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);
}
@@ -306,13 +336,31 @@ 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.
@@ -320,7 +368,9 @@ 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)?;
@@ -333,12 +383,30 @@ pub fn run(
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = model {
let (a, e) = apply_model(app, agent, m)?;
// 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 exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
@@ -351,6 +419,9 @@ pub fn run(
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)
@@ -474,6 +545,70 @@ pub fn apply_model(
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()?;
@@ -483,3 +618,143 @@ 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,
}),
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();
}
}
+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__" {
+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),
)
}
+5
View File
@@ -190,12 +190,17 @@ mod tests {
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,
sandbox: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false,
note: None,
+53
View File
@@ -103,6 +103,34 @@ pub static SECTIONS: &[TipSection] = &[
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)",
@@ -119,6 +147,15 @@ pub static SECTIONS: &[TipSection] = &[
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)",
@@ -251,6 +288,16 @@ pub static SECTIONS: &[TipSection] = &[
],
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)",
@@ -323,6 +370,12 @@ pub static SECTIONS: &[TipSection] = &[
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",
+307
View File
@@ -136,9 +136,148 @@ pub struct Settings {
/// 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
@@ -248,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)]
@@ -270,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>,
@@ -289,6 +456,22 @@ pub struct AgentDef {
/// 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>,
/// 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.
@@ -372,6 +555,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
@@ -546,6 +735,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 {
@@ -559,6 +752,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 {
@@ -766,9 +986,45 @@ pub fn merge(base: &mut Config, overlay: Config) {
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);
}
@@ -834,6 +1090,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
}
@@ -933,6 +1215,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");
+10
View File
@@ -48,6 +48,13 @@ pub enum EventKind {
Alert,
/// One benchmark run of 'am lab' (issue #62).
Lab,
/// LLM provider registry change: add, remove, default (issue #88).
Provider,
/// Sandbox refusal journalized for audit (issue #79).
Sandbox,
/// am ai — Shell AI action: generated, classified and/or executed
/// (issues #96 #97).
ShellAi,
}
impl EventKind {
@@ -73,6 +80,9 @@ impl EventKind {
EventKind::Cost => "cost",
EventKind::Alert => "alert",
EventKind::Lab => "lab",
EventKind::Provider => "provider",
EventKind::Sandbox => "sandbox",
EventKind::ShellAi => "shell_ai",
}
}
}
+195 -8
View File
@@ -156,6 +156,24 @@ const START_FLAGS: &[HelpFlag] = &[
value: "",
desc: "Run inside the agent's container profile (issue #58)",
},
HelpFlag {
short: "",
long: "--no-sandbox",
value: "",
desc: "Skip the agent's sandbox profile (issue #79)",
},
HelpFlag {
short: "",
long: "--model",
value: "MODEL",
desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)",
},
HelpFlag {
short: "",
long: "--provider",
value: "PROVIDER",
desc: "Provider of the registry to use (base_url + keyring token + model) (issue #92)",
},
];
pub static HELP_SPECS: &[HelpSpec] = &[
@@ -664,18 +682,54 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "secret",
category: "Commands",
usage: "secret {flags} <set|unset|list>",
about: "Manage secrets in the OS keyring (values never shown or logged).",
about: "Manage secrets in the OS keyring (values never shown or logged). Secrets are scoped to an agent (agent/NAME) or to a provider (providers/<p>/<VAR>, shared by every agent of that provider, issue #89).",
search_terms: &["keyring", "token", "password"],
flags: &[],
subcommands: &[
("set", "Store a secret for an agent"),
("set", "Store a secret for an agent or a provider"),
("unset", "Remove a secret"),
],
parameters: &[
HelpParam { name: "--agent", typ: "string", desc: "Agent the secret belongs to" },
HelpParam { name: "--provider", typ: "string", desc: "Provider the secret belongs to (issue #89)" },
HelpParam { name: "--value", typ: "string", desc: "Secret value (never logged)" },
],
io: None,
examples: &[
HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." },
HelpExample { desc: "Store a provider token.", code: "secret set api_key --provider openai --value sk-... (or: providers set-token openai)" },
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
],
},
HelpSpec {
name: "providers",
category: "Commands",
usage: "providers {flags} <list|show|add|remove|default>",
about: "Manage the LLM provider registry: base URLs, models, the default model and the default provider (issue #88). Tokens are never stored here — they live in the OS keyring under providers/<name>/api_key (issue #89).",
search_terms: &["provider", "token", "registry", "llm", "model", "api", "keyring"],
flags: &[
HelpFlag { short: "", long: "--base-url", value: "URL", desc: "Base URL of the provider API (add)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model used by default for this provider (add)" },
HelpFlag { short: "", long: "--models", value: "MODELS", desc: "Comma-separated model list (add)" },
HelpFlag { short: "", long: "--value", value: "VALUE", desc: "Token value (set-token; never logged)" },
],
subcommands: &[
("list", "List the registered providers (default starred)"),
("show", "Show one provider in detail"),
("add", "Register or update a provider"),
("remove", "Remove a provider"),
("default", "Set the default provider"),
("set-token", "Store the provider token in the OS keyring (issue #89)"),
("token", "Check whether a provider token exists (value never shown)"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." },
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
HelpExample { desc: "List the providers.", code: "providers" },
HelpExample { desc: "Register DeepSeek.", code: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat --models deepseek-chat,deepseek-reasoner" },
HelpExample { desc: "Store its shared token.", code: "providers set-token deepseek --value sk-..." },
HelpExample { desc: "Make it the default.", code: "providers default deepseek" },
HelpExample { desc: "Machine-readable list.", code: "providers --json" },
],
},
HelpSpec {
@@ -761,11 +815,14 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "install",
category: "Commands",
usage: "install {flags} <agent>",
about: "Install an agent and its dependencies.",
search_terms: &["add", "setup"],
about: "Install an agent and its dependencies, and resolve its LLM provider + model (issue #90): --provider > agent.provider > settings.default_provider; --model > agent.model > provider.default_model.",
search_terms: &["add", "setup", "provider", "model"],
flags: &[
HelpFlag { short: "", long: "--method", value: "METHOD", desc: "Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)" },
HelpFlag { short: "", long: "--force", value: "", desc: "Reinstall even if already installed" },
HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider to configure (default: the agent's provider, else settings.default_provider)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to configure (default: the provider's default model)" },
HelpFlag { short: "", long: "--no-config", value: "", desc: "Skip the post-install provider configuration (issue #91)" },
],
subcommands: &[],
parameters: &[
@@ -776,6 +833,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Install an agent with its dependencies.", code: "install claude-code" },
HelpExample { desc: "Pick a specific install method.", code: "install smelt --method uv" },
HelpExample { desc: "Reinstall over an existing install.", code: "install claude-code --force" },
HelpExample { desc: "Force a provider and a model.", code: "install agentty --provider deepseek --model deepseek-reasoner" },
HelpExample { desc: "Preview the resolved provider/model without installing.", code: "install agentty --dry-run" },
HelpExample { desc: "Install without any post-install configuration.", code: "install agentty --no-config" },
],
},
HelpSpec {
@@ -797,6 +857,123 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
],
},
HelpSpec {
name: "setup",
category: "Commands",
usage: "setup {flags}",
about: "Wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat et génération des rôles copilot am-* (épique v1.1.0). Ré-exécutable à tout moment (mode revoir).",
search_terms: &["wizard", "onboarding", "provider", "token", "setup", "configurer", "premier"],
flags: &[
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Mode non-interactif : garde les valeurs actuelles (provider/modèle), installe aichat, régénère les rôles" },
HelpFlag { short: "", long: "--roles", value: "", desc: "Régénérer uniquement les rôles copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Premier lancement (proposé aussi par am ai / le banner REPL)", code: "am setup" },
HelpExample { desc: "Régénérer les rôles après une mise à jour", code: "am setup --roles" },
HelpExample { desc: "Non-interactif (scripts)", code: "am setup --yes" },
],
},
HelpSpec {
name: "ask",
category: "Commands",
usage: "ask {flags} <demande...>",
about: "Traduit une demande en langage naturel en commande(s) am : règles locales hors-ligne, puis raffinement LLM optionnel via le registre providers, cache des traductions, confirmation avant exécution (issue #78).",
search_terms: &["nlp", "natural", "language", "traduire", "langage"],
flags: &[
HelpFlag { short: "", long: "--yes", value: "", desc: "Skip the confirmation prompt" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "demande", typ: "string", desc: "La demande en langage naturel, ex: «installe claude et lance-le»" },
],
io: None,
examples: &[
HelpExample { desc: "Installer puis lancer un agent.", code: "ask installe claude et lance-le" },
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
],
},
HelpSpec {
name: "ai",
category: "Commands",
usage: "ai {flags} <prompt...>",
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
flags: &[
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" },
HelpFlag { short: "", long: "--role", value: "ROLE", desc: "Rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator — générés par am setup)" },
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" },
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "prompt", typ: "string", desc: "La demande en langage naturel, ex: «liste les fichiers JSON du dossier courant»" },
],
io: None,
examples: &[
HelpExample { desc: "Mode conversationnel (défaut).", code: "ai liste tous les fichiers JSON et extrait les clés uniques" },
HelpExample { desc: "Générer la commande sans l'exécuter (dry-run par défaut).", code: "ai --exec \"compresse les fichiers JSON en un zip\"" },
HelpExample { desc: "Exécuter après confirmation explicite.", code: "ai --exec \"supprime les fichiers .tmp\" --yes" },
HelpExample { desc: "Passer un dossier en contexte.", code: "ai --files ./data \"résume les données de ces fichiers\"" },
],
},
HelpSpec {
name: "registry",
category: "Commands",
usage: "registry <publish|search|install|list>",
about: "Registre communautaire (issue #77) : publier un catalogue + manifeste (source, version, auteur, sha256) sur Gitea, chercher des agents dans les sources enregistrées, et installer un catalogue avec validation du checksum et décision de confiance explicite.",
search_terms: &["community", "publish", "manifest", "sha256", "confiance"],
flags: &[
HelpFlag { short: "", long: "--source", value: "URL", desc: "URL publique du catalogue publié (publish)" },
HelpFlag { short: "", long: "--author", value: "NOM", desc: "Auteur enregistré dans le manifeste (publish)" },
HelpFlag { short: "", long: "--version", value: "V", desc: "Version du catalogue, défaut 1.0.0 (publish)" },
HelpFlag { short: "", long: "--yes", value: "", desc: "Accepter la source sans confirmation (install)" },
],
subcommands: &[
("registry publish", "prépare le manifeste (sha256) à côté du catalogue"),
("registry install", "installe avec validation checksum + confiance"),
],
parameters: &[
HelpParam { name: "mot", typ: "string", desc: "Mot-clé de recherche (nom, description ou tag d'agent)" },
],
io: None,
examples: &[
HelpExample { desc: "Préparer un catalogue pour publication.", code: "registry publish am-catalog.yaml --source https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --author bruno" },
HelpExample { desc: "Chercher un agent dans les sources enregistrées.", code: "registry search claude" },
HelpExample { desc: "Installer un catalogue en faisant confiance à la source.", code: "registry install https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --yes" },
],
},
HelpSpec {
name: "serve",
category: "Commands",
usage: "serve --token <TOKEN> [--host HOST] [--port PORT] [--rate-limit N]",
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (issue #80) : token obligatoire sur chaque requête, contrats --json réutilisés (stats, sessions, ps, run, start, stop, ask), WebSocket diffusant les événements du journal en temps réel, rate limiting par IP. TLS : placez le serveur derrière un reverse proxy (caddy/nginx).",
search_terms: &["api", "http", "websocket", "token", "remote", "serveur"],
flags: &[
HelpFlag { short: "", long: "--token", value: "TOKEN", desc: "Bearer token requis sur chaque requête (obligatoire)" },
HelpFlag { short: "", long: "--host", value: "HOST", desc: "Adresse d'écoute (défaut 127.0.0.1)" },
HelpFlag { short: "", long: "--port", value: "PORT", desc: "Port d'écoute (défaut 8080)" },
HelpFlag { short: "", long: "--rate-limit", value: "N", desc: "Budget de requêtes par minute et par IP (défaut 120)" },
],
subcommands: &[
("/api/health", "GET — {ok, version}"),
("/api/stats", "GET — am stats --json"),
("/api/run", "POST {agent, model?, provider?, args?} — am run --json"),
("/ws", "GET — WebSocket : événements du journal en temps réel"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Démarrer le serveur.", code: "serve --token MonTokenSecret --port 8080" },
HelpExample { desc: "Appeler une route (depuis une autre machine).", code: "curl -H \"Authorization: Bearer MonTokenSecret\" http://serveur:8080/api/stats" },
HelpExample { desc: "Lancer un agent à distance.", code: "curl -X POST -H \"Authorization: Bearer MonTokenSecret\" -d '{\"agent\":\"claude-code\"}' http://serveur:8080/api/run" },
],
},
HelpSpec {
name: "start",
category: "Commands",
@@ -869,7 +1046,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[
about: "Run the agent command directly with the given arguments (no process management).",
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
flags: &[
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Local model to use (issue #71)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)" },
HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider registry override at launch (issue #92)" },
HelpFlag { short: "", long: "--no-sandbox", value: "", desc: "Skip the agent's sandbox profile for this run (issue #79)" },
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
],
subcommands: &[],
@@ -880,6 +1059,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
io: None,
examples: &[
HelpExample { desc: "Run an agent command directly.", code: "run claude-code" },
HelpExample { desc: "Use a provider of the registry.", code: "run claude-code --provider anthropic --model claude-sonnet-4" },
HelpExample { desc: "Pass --help through to the agent (after --).", code: "run claude-code -- --help" },
],
},
@@ -2131,7 +2311,14 @@ mod tests {
assert!(text.contains("build_target"));
assert!(text.contains("allocator"));
assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
assert_eq!(info.major, 0);
// The major/minor version follows Cargo.toml: parse instead of pinning.
let major: u64 = env!("CARGO_PKG_VERSION")
.split('.')
.next()
.unwrap()
.parse()
.unwrap();
assert_eq!(info.major, major);
// The minor version follows Cargo.toml: parse it instead of pinning it.
let minor: u64 = env!("CARGO_PKG_VERSION")
.split('.')
+69 -1
View File
@@ -159,7 +159,66 @@ pub const CATALOG: &[(&str, &str)] = &[
("date", "date"),
("début", "start"),
("fin", "end"),
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
("am ask est désactivé — settings.ask.enabled: true pour l'activer", "am ask is disabled — set settings.ask.enabled: true to enable it"),
("je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»", "I did not understand — examples: «installe claude et lance-le», «liste les agents», «arrête codex»"),
("exécuter ces commandes ?", "run these commands?"),
("annulé", "cancelled"),
("Exécuter ?", "Execute?"),
("dry-run : commande non exécutée", "dry-run: command not executed"),
("politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes", "safety policy (dry-run by default): command not executed — use --yes"),
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"),
("usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai <request> — e.g. ai --exec \"compress the JSON files\""),
("am n'est pas configuré — lancez am setup (provider + token)", "am is not configured — run am setup (provider + token)"),
("Configuration d'agent-manager (am setup)", "agent-manager configuration (am setup)"),
("Le token est stocké dans le trousseau OS — jamais en clair.", "The token is stored in the OS keyring — never in clear."),
("Installer le moteur IA (aichat) ?", "Install the AI engine (aichat)?"),
("Installation d'aichat…", "Installing aichat…"),
("aichat est manquant. Installer ?", "aichat is missing. Install it?"),
("installez aichat: am install aichat — puis réessayez", "install aichat: am install aichat — then retry"),
("Rôles copilot am-* générés", "am-* copilot roles generated"),
("Config aichat créée (provider + modèle)", "aichat config created (provider + model)"),
("am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"", "am is configured — try: am ai \"...\" or am ai --exec \"...\""),
("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)", "unknown role '{}' — generate the roles with: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
("installation refusée — source non fiable", "installation refused — untrusted source"),
("usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list", "usage: registry publish <catalog.yaml> --source <url> | registry search <query> | registry install <url> | registry list"),
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest un agent pour du Python"),
// ---- providers (#88) ---------------------------------------------------
("aucun provider enregistré — voir: am providers add <nom> --base-url <url>", "no providers registered — see: am providers add <name> --base-url <url>"),
("provider '{}' inconnu", "unknown provider '{}'"),
("nom de provider invalide '{}' — attendu un slug [a-z0-9_-]", "invalid provider name '{}' — expected a slug [a-z0-9_-]"),
("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]", "usage: providers add <name> --base-url <url> [--model <m>] [--models m1,m2]"),
("provider '{}' mis à jour", "provider '{}' updated"),
("provider '{}' ajouté", "provider '{}' added"),
("positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)", "store the token in the keyring: am providers set-token {} (issue #89, coming)"),
("provider '{}' supprimé", "provider '{}' removed"),
("le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", "the default provider '{}' cannot be removed — change the default provider first"),
("provider par défaut : {}", "default provider: {}"),
("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
("token stocké pour le provider '{}'", "token stored for provider '{}'"),
("token présent pour le provider '{}'", "token present for provider '{}'"),
("aucun token pour le provider '{}' — voir: am providers set-token {}", "no token for provider '{}' — see: am providers set-token {}"),
("secret '{}' stocké pour {}", "secret '{}' stored for {}"),
("secret '{}' supprimé pour {}", "secret '{}' removed for {}"),
("secret '{}' inexistant pour {}", "secret '{}' does not exist for {}"),
("--no-config : aucune configuration post-install", "--no-config: no post-install configuration"),
("{} ne prend aucune configuration (install.configurable: false)", "{} takes no configuration (install.configurable: false)"),
("provider '{}' inconnu — providers enregistrés: {}", "unknown provider '{}' — registered providers: {}"),
("provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url <url>", "provider '{}' (declared by {}) is not registered — see: am providers add {} --base-url <url>"),
("aucun provider configuré — voir: am providers add <nom> --base-url <url>", "no provider configured — see: am providers add <name> --base-url <url>"),
("modèle '{}' absent de la liste du provider '{}' — modèles: {}", "model '{}' is not in provider '{}' model list — models: {}"),
("configuré avec le provider {} (modèle {})", "configured with provider {} (model {})"),
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"),
("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"),
("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"),
("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"),
("clé '{}' inconnue dans config.files de {}", "unknown key '{}' in config.files of {}"),
("config écrite: {}", "config written: {}"),
("usage: secret set <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>", "usage: secret set <name> --agent <agent> --value <value> | secret set <name> --provider <provider> --value <value>"),
("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"),
("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"),
("vouliez-vous dire", "did you mean"),
@@ -388,6 +447,15 @@ pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
}
}
/// Format a message for a GIVEN locale (deterministic — follows the app's
/// resolved language instead of the process-global locale).
pub fn tr_fmt_in<'a>(lang: Lang, key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
match lang {
Lang::Fr => fmt_args(key, args),
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
}
}
/// Replace `{}` placeholders (positional) with the Display of each argument.
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
let mut out = String::with_capacity(template.len() + 16);
+16 -1
View File
@@ -12,6 +12,11 @@
//! * commands — one module per CLI command.
pub mod app;
pub mod agent_config;
pub mod ask;
pub mod roles;
pub mod setup;
pub mod shell_ai;
pub mod automation;
pub mod backup;
pub mod catalog;
@@ -37,16 +42,21 @@ pub mod models;
pub mod nav;
pub mod output;
pub mod probe;
pub mod providers;
pub mod projects;
pub mod ps;
pub mod process;
pub mod registry;
pub mod repl;
pub mod runner;
pub mod sandbox;
pub mod secrets;
pub mod serve;
pub mod sessions;
pub mod shell;
pub mod state;
pub mod sync;
pub mod telemetry;
pub mod playbook;
pub mod plugins;
pub mod tables;
@@ -103,8 +113,13 @@ fn real_main() -> i32 {
app.log.verbose(&format!("session retention skipped: {e:#}"));
}
match commands::execute(&app) {
Ok(code) => code,
Ok(code) => {
// Issue #76: batched anonymous telemetry, fire-and-forget.
crate::telemetry::maybe_flush(&app);
code
}
Err(err) => {
crate::telemetry::maybe_flush(&app);
if app.json() {
let payload = serde_json::json!({
"error": format!("{:#}", err),
+33 -5
View File
@@ -79,6 +79,11 @@ struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)]
path_hash: u64,
/// FNV-1a hash of the catalog's detect tokens. When the catalog changes
/// which binaries it probes (new agent, `detect` added), the token
/// index must be rebuilt even if the PATH is unchanged.
#[serde(default)]
token_hash: u64,
/// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)]
tokens: BTreeMap<String, Option<String>>,
@@ -98,6 +103,26 @@ fn path_hash() -> u64 {
h
}
/// Hash of the sorted catalog detect tokens: cache invalidation for catalog
/// changes (new agent, added `detect` field) without re-scanning the PATH.
fn catalog_token_hash(app: &App) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut tokens: Vec<String> = app
.catalog
.agents()
.iter()
.filter_map(|a| a.detect_token())
.collect();
tokens.sort();
for t in tokens {
for b in t.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Build an index of every executable on the PATH in a single pass.
/// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> {
@@ -163,13 +188,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
.unwrap_or_default();
let mut dirty = false;
// Resolve the run-command tokens of the catalog (once per PATH change).
// Resolve the detect tokens of the catalog (once per PATH change or
// catalog change).
let current_hash = path_hash();
if cache.path_hash != current_hash {
let current_tokens = catalog_token_hash(app);
if cache.path_hash != current_hash || cache.token_hash != current_tokens {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
@@ -180,6 +207,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
}
}
cache.path_hash = current_hash;
cache.token_hash = current_tokens;
dirty = true;
}
@@ -195,7 +223,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
@@ -211,7 +239,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue;
}
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if let Some(Some(path)) = cache.tokens.get(&token) {
+197
View File
@@ -0,0 +1,197 @@
//! LLM provider registry (issue #88): resolution helpers over the
//! `settings.providers` map — the default provider, its default model and
//! name validation. Tokens are deliberately out of scope here: they live in
//! the OS keyring under `providers/<name>/api_key` (issue #89).
//!
//! The install-time configuration (issues #90, #92) will resolve the
//! provider/model of an agent through these helpers.
use crate::config::{Config, ProviderDef};
use std::collections::BTreeMap;
/// Empty provider map shared by the helpers below (never mutated).
static EMPTY: BTreeMap<String, Option<ProviderDef>> = BTreeMap::new();
/// Every provider of the effective configuration (empty when unset).
/// `None` values are the YAML null-deletion markers and are skipped.
pub fn all(config: &Config) -> &BTreeMap<String, Option<ProviderDef>> {
config.settings.providers.as_ref().unwrap_or(&EMPTY)
}
/// The providers actually defined (null markers filtered out).
pub fn defined(config: &Config) -> impl Iterator<Item = (&String, &ProviderDef)> {
all(config)
.iter()
.filter_map(|(k, v)| v.as_ref().map(|def| (k, def)))
}
/// Look up one provider by name.
pub fn get<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderDef> {
config.settings.provider(name)
}
/// Name of the default provider, when set and defined.
pub fn default_name(config: &Config) -> Option<&str> {
let (name, _) = config.settings.default_provider()?;
Some(name.as_str())
}
/// The default provider entry (name + definition).
pub fn default(config: &Config) -> Option<(&String, &ProviderDef)> {
config.settings.default_provider()
}
/// Model to use for a provider: the explicit model when given, otherwise
/// the provider's default model.
pub fn model_for<'a>(provider: &'a ProviderDef, explicit: Option<&'a str>) -> Option<&'a str> {
explicit
.filter(|m| !m.is_empty())
.or(provider.default_model.as_deref())
}
/// Sorted provider names (for error messages listing the registry).
pub fn names(config: &Config) -> Vec<String> {
let mut v: Vec<String> = defined(config).map(|(k, _)| k.clone()).collect();
v.sort();
v
}
/// Resolve the provider + model of an agent at install time (issue #90):
/// `flag --provider` > `agent.provider` > `settings.default_provider` ;
/// `flag --model` > `agent.model` > `provider.default_model`.
/// Returns None when no provider is configured at all.
pub fn resolve_for<'a>(
config: &'a Config,
agent_provider: Option<&'a str>,
agent_model: Option<&'a str>,
flag_provider: Option<&'a str>,
flag_model: Option<&'a str>,
) -> Option<(&'a str, &'a ProviderDef, Option<&'a str>)> {
let provider_name = flag_provider
.filter(|p| !p.is_empty())
.or(agent_provider.filter(|p| !p.is_empty()))
.or_else(|| default_name(config))?;
let def = get(config, provider_name)?;
let model = model_for(
def,
flag_model
.filter(|m| !m.is_empty())
.or(agent_model.filter(|m| !m.is_empty())),
);
Some((provider_name, def, model))
}
/// Provider names accept the same slug rule as agent names: [a-z0-9_-].
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, ProviderDef};
fn config_with() -> Config {
let mut c = Config::default();
c.settings.default_provider = Some("openai".to_string());
let mut providers = BTreeMap::new();
providers.insert(
"openai".to_string(),
Some(ProviderDef {
base_url: "https://api.openai.com/v1".to_string(),
default_model: Some("gpt-5.2".to_string()),
models: vec!["gpt-5.2".to_string(), "gpt-5.1-mini".to_string()],
}),
);
providers.insert(
"deepseek".to_string(),
Some(ProviderDef {
base_url: "https://api.deepseek.com".to_string(),
default_model: None,
models: vec!["deepseek-chat".to_string()],
}),
);
c.settings.providers = Some(providers);
c
}
#[test]
fn resolves_the_default_provider() {
let c = config_with();
let (name, def) = default(&c).expect("default provider");
assert_eq!(name, "openai");
assert_eq!(def.base_url, "https://api.openai.com/v1");
assert_eq!(default_name(&c), Some("openai"));
}
#[test]
fn explicit_model_wins_over_the_default() {
let c = config_with();
let def = get(&c, "deepseek").unwrap();
assert_eq!(model_for(def, None), None);
assert_eq!(model_for(def, Some("deepseek-reasoner")), Some("deepseek-reasoner"));
let openai = get(&c, "openai").unwrap();
assert_eq!(model_for(openai, None), Some("gpt-5.2"));
assert_eq!(model_for(openai, Some("gpt-5.1-mini")), Some("gpt-5.1-mini"));
}
#[test]
fn default_missing_provider_is_none() {
let mut c = config_with();
c.settings.default_provider = Some("nope".to_string());
assert!(default(&c).is_none());
assert!(default_name(&c).is_none());
}
#[test]
fn name_validation_follows_the_slug_rule() {
assert!(is_valid_name("openai"));
assert!(is_valid_name("deep-seek_2"));
assert!(!is_valid_name("OpenAI"));
assert!(!is_valid_name(""));
assert!(!is_valid_name("has space"));
}
#[test]
fn resolve_for_flag_beats_agent_beats_default() {
let c = config_with();
// 1. Rien de précisé → default_provider (openai) + son default_model.
let (p, _, m) = resolve_for(&c, None, None, None, None).unwrap();
assert_eq!(p, "openai");
assert_eq!(m, Some("gpt-5.2"));
// 2. Le provider déclaré par l'agent gagne sur le défaut.
let (p, _, m) = resolve_for(&c, Some("deepseek"), None, None, None).unwrap();
assert_eq!(p, "deepseek");
assert_eq!(m, None, "deepseek n'a pas de default_model");
// 3. Le flag --provider gagne sur tout.
let (p, _, m) = resolve_for(&c, Some("openai"), None, Some("deepseek"), None).unwrap();
assert_eq!(p, "deepseek");
// 4. --model flag > agent.model > default_model.
let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, None).unwrap();
assert_eq!(m, Some("o4-mini"));
let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, Some("gpt-5.1-mini")).unwrap();
assert_eq!(m, Some("gpt-5.1-mini"));
}
#[test]
fn resolve_for_none_without_any_provider() {
let mut c = config_with();
c.settings.default_provider = None;
c.settings.providers = None;
assert!(resolve_for(&c, None, None, None, None).is_none());
// Un agent qui déclare son provider résout même sans défaut.
assert!(resolve_for(&c, Some("openai"), None, None, None).is_none());
let mut c2 = config_with();
c2.settings.default_provider = None;
assert_eq!(resolve_for(&c2, Some("openai"), None, None, None).unwrap().0, "openai");
}
#[test]
fn names_are_sorted() {
let c = config_with();
assert_eq!(names(&c), vec!["deepseek".to_string(), "openai".to_string()]);
}
}
+484
View File
@@ -0,0 +1,484 @@
//! Community registry (issue #77): publish, search and install agent
//! catalogs hosted on Gitea.
//!
//! A published catalog ships with a manifest (`am-manifest.json`) recording
//! source, version, author, agent count and the sha256 of the catalog file.
//! `am registry install` validates the checksum and requires an explicit
//! trust decision for unknown sources — an untrusted source is refused.
use crate::app::App;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
/// Name of the manifest file, expected next to the catalog file.
pub const MANIFEST_NAME: &str = "am-manifest.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema: u32,
pub name: String,
pub version: String,
pub author: String,
/// Public URL of the catalog file this manifest describes.
pub source: String,
pub agents_count: usize,
/// Lowercase hex sha256 of the catalog file content.
pub sha256: String,
pub published_at: String,
}
/// sha256 of some bytes, lowercase hex.
pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let digest = hasher.finalize();
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// The manifest URL living next to a catalog URL: same base, file name
/// replaced by `am-manifest.json` (or appended when the URL is a directory).
pub fn manifest_url_of(catalog_url: &str) -> String {
let trimmed = catalog_url.trim_end_matches('/');
let last = trimmed.rsplit('/').next().unwrap_or("");
if last.contains('.') && !last.contains('?') {
// File URL: replace the file name.
let base = trimmed.trim_end_matches(last);
format!("{base}{MANIFEST_NAME}")
} else {
// Directory URL: append.
format!("{trimmed}/{MANIFEST_NAME}")
}
}
/// Build and write the manifest next to a catalog file (dry-run safe).
pub fn publish(
app: &App,
catalog_path: &Path,
source: Option<&str>,
author: Option<&str>,
version: Option<&str>,
) -> Result<i32> {
let text = std::fs::read_to_string(catalog_path)
.map_err(|e| anyhow!("cannot read {}: {e}", catalog_path.display()))?;
let remote = crate::catalog_remote::parse_catalog(&text)?;
if remote.agents.is_empty() {
anyhow::bail!("the catalog defines no agents — refusing to publish");
}
let Some(source) = source.filter(|s| !s.trim().is_empty()) else {
anyhow::bail!(
"--source <url> is required: the public URL of the catalog once pushed \
(e.g. https://git.dracodev.net/<user>/<repo>/raw/branch/main/am-catalog.yaml)"
);
};
let author = author
.filter(|a| !a.trim().is_empty())
.or_else(|| {
app.config
.settings
.registry
.as_ref()
.and_then(|r| r.author.as_deref())
})
.unwrap_or("unknown")
.to_string();
let name = catalog_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "catalog".to_string());
let manifest = Manifest {
schema: 1,
name,
version: version.unwrap_or("1.0.0").to_string(),
author,
source: source.to_string(),
agents_count: remote.agents.len(),
sha256: sha256_hex(text.as_bytes()),
published_at: crate::installers::now_rfc3339(),
};
let manifest_path = catalog_path.with_file_name(MANIFEST_NAME);
if app.json() {
print!("{}", serde_json::to_string_pretty(&manifest)?);
println!();
return Ok(0);
}
if app.dry_run() {
app.log.dry(&format!(
"would write {} ({} agents, sha256 {})",
manifest_path.display(),
manifest.agents_count,
&manifest.sha256[..12.min(manifest.sha256.len())]
));
return Ok(0);
}
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)
.map_err(|e| anyhow!("cannot write {}: {e}", manifest_path.display()))?;
app.log.success(&format!(
"manifest written: {} ({} agents, sha256 {})",
manifest_path.display(),
manifest.agents_count,
&manifest.sha256[..12]
));
app.log.info(&format!(
"push both files to your Gitea repo, then register/install with:\n am registry install {source}"
));
Ok(0)
}
/// Fetch + validate the manifest of a catalog URL.
pub fn fetch_manifest(app: &App, catalog_url: &str) -> Result<Manifest> {
let murl = manifest_url_of(catalog_url);
let text = crate::catalog_remote::fetch(app, &murl, true)?;
let manifest: Manifest = serde_json::from_str(&text)
.map_err(|e| anyhow!("invalid manifest at {murl}: {e}"))?;
if manifest.sha256.len() != 64 {
anyhow::bail!("manifest at {murl} has an invalid sha256");
}
if manifest.source != catalog_url {
anyhow::bail!(
"manifest source mismatch: declares {} but requested {} — refused",
manifest.source,
catalog_url
);
}
Ok(manifest)
}
/// Registered sources of the community registry.
pub fn sources(app: &App) -> Vec<String> {
app.config
.settings
.registry
.as_ref()
.map(|r| r.sources.clone())
.unwrap_or_default()
}
/// am registry search <query>: probe every registered source, list the
/// agents matching the query (name, description, tags).
pub fn search(app: &App, query: &str) -> Result<i32> {
let sources = sources(app);
if sources.is_empty() {
app.log.info(crate::i18n::tr(
"aucune source enregistrée — voir settings.registry.sources ou: am registry install <url>",
));
return Ok(0);
}
let q = query.trim().to_lowercase();
let mut found = 0usize;
for url in &sources {
let Ok(manifest) = fetch_manifest(app, url) else {
app.log
.verbose(&format!("registry source {url}: manifest unavailable"));
continue;
};
let Ok(text) = crate::catalog_remote::fetch(app, url, true) else {
continue;
};
let Ok(remote) = crate::catalog_remote::parse_catalog(&text) else {
continue;
};
let matches: Vec<&crate::config::AgentDef> = remote
.agents
.iter()
.filter(|a| {
q.is_empty()
|| a.name.to_lowercase().contains(&q)
|| a.description.as_deref().unwrap_or("").to_lowercase().contains(&q)
|| a.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.collect();
if matches.is_empty() {
continue;
}
found += matches.len();
app.log.info(&format!(
"{} v{} par {} ({})",
manifest.name, manifest.version, manifest.author, url
));
for a in &matches {
println!(" {} — {}", a.name, a.description.as_deref().unwrap_or(""));
}
}
if found == 0 {
app.log.info(&format!("aucun agent ne correspond à «{query}»"));
}
Ok(0)
}
/// am registry install <url>: manifest + checksum + trust, then register the
/// catalog as a remote include (same mechanism as `am catalog add`).
pub fn install(app: &App, url: &str, yes: bool) -> Result<i32> {
crate::download::validate_url(url)?;
app.log.info(&format!("fetching manifest: {}", manifest_url_of(url)));
let manifest = fetch_manifest(app, url)?;
app.log.info(&format!("fetching catalog: {url}"));
let text = crate::catalog_remote::fetch(app, url, true)?;
let actual = sha256_hex(text.as_bytes());
if actual != manifest.sha256 {
anyhow::bail!(
"checksum mismatch — expected {} got {} (source non fiable, refusée)",
&manifest.sha256[..12],
&actual[..12]
);
}
let remote = crate::catalog_remote::parse_catalog(&text)?;
if remote.agents.is_empty() {
anyhow::bail!("the catalog defines no agents — refusing to install");
}
// Trust: a source already registered (settings.registry.sources or an
// active include) is trusted; anything else needs an explicit decision.
let known = sources(app).iter().any(|s| s == url)
|| crate::commands::config_cmd::user_includes(app)
.iter()
.any(|i| i == url);
if !known && !yes {
if !app.confirm(&format!(
"source non fiable : {} (auteur {}, v{}) — faire confiance ?",
url, manifest.author, manifest.version
))? {
app.log.info(crate::i18n::tr("installation refusée — source non fiable"));
return Ok(0);
}
}
if app.dry_run() {
app.log.dry(&format!(
"would register {} ({} agents, auteur {}, v{})",
url, manifest.agents_count, manifest.author, manifest.version
));
return Ok(0);
}
crate::commands::config_cmd::register_include(app, url)?;
app.emit(
&crate::events::Event::now(crate::events::EventKind::Catalog)
.with_args(vec![url.to_string()])
.with_reason("registry-install"),
);
app.log.success(&format!(
"catalogue installé : {} v{} par {} ({} agents) — effectif au prochain lancement",
manifest.name, manifest.version, manifest.author, manifest.agents_count
));
Ok(0)
}
/// am registry list: the registered sources.
pub fn list(app: &App) -> Result<i32> {
let sources = sources(app);
if sources.is_empty() {
app.log.info(crate::i18n::tr(
"aucune source enregistrée — settings.registry.sources ou: am registry install <url>",
));
return Ok(0);
}
if app.json() {
print!("{}", serde_json::to_string_pretty(&sources)?);
println!();
return Ok(0);
}
for s in &sources {
println!(" {s}");
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn sha256_hex_is_64_chars_and_stable() {
let h1 = sha256_hex(b"hello");
let h2 = sha256_hex(b"hello");
assert_eq!(h1.len(), 64);
assert_eq!(h1, h2);
assert_ne!(sha256_hex(b"hello!"), h1);
}
#[test]
fn manifest_url_replaces_the_file_name() {
assert_eq!(
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/am-catalog.yaml"),
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
);
}
#[test]
fn manifest_url_appends_on_a_directory() {
assert_eq!(
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/"),
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
);
}
#[test]
fn publish_writes_the_manifest_next_to_the_catalog() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
let catalog = dir.join("am-catalog.yaml");
std::fs::write(
&catalog,
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
)
.unwrap();
let app = crate::app::App::from_cli(
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
)
.unwrap();
let code = publish(
&app,
&catalog,
Some("https://git.dracodev.net/x/y/raw/branch/main/am-catalog.yaml"),
Some("bruno"),
Some("2.0.0"),
)
.unwrap();
assert_eq!(code, 0);
let manifest_path = dir.join(MANIFEST_NAME);
assert!(manifest_path.exists(), "manifest must be written");
let m: Manifest =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert_eq!(m.author, "bruno");
assert_eq!(m.version, "2.0.0");
assert_eq!(m.agents_count, 1);
assert_eq!(m.name, "am-catalog");
assert_eq!(
m.sha256,
sha256_hex(std::fs::read(&catalog).unwrap().as_slice())
);
}
#[test]
fn publish_refuses_empty_catalogs_and_missing_source() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
let catalog = dir.join("empty.yaml");
std::fs::write(&catalog, "version: \"1.0\"\nagents: []\n").unwrap();
let app = crate::app::App::from_cli(
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
)
.unwrap();
assert!(publish(&app, &catalog, Some("https://x/y.yaml"), None, None).is_err());
// Missing source.
let catalog2 = dir.join("ok.yaml");
std::fs::write(
&catalog2,
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
)
.unwrap();
let err = publish(&app, &catalog2, None, None, None).unwrap_err();
assert!(err.to_string().contains("--source"));
}
#[test]
fn install_end_to_end_checks_checksum_and_trust() {
// A tiny_http server serves the catalog + its manifest. The install
// must validate the sha256 and register the include.
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let catalog_body = "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n";
let sha = sha256_hex(catalog_body.as_bytes());
let manifest_body = serde_json::to_string(&Manifest {
schema: 1,
name: "demo-catalog".to_string(),
version: "1.0.0".to_string(),
author: "bruno".to_string(),
source: format!("http://127.0.0.1:{port}/am-catalog.yaml"),
agents_count: 1,
sha256: sha.clone(),
published_at: "now".to_string(),
})
.unwrap();
let handle = std::thread::spawn(move || {
// 2 requests per install (manifest + catalog) × 2 installs.
for _ in 0..4 {
let mut req = server
.recv_timeout(std::time::Duration::from_secs(10))
.expect("server recv failed")
.expect("the registry install must fetch manifest + catalog");
let body = if req.url().ends_with("am-manifest.json") {
manifest_body.clone()
} else {
catalog_body.to_string()
};
let _ = req.respond(tiny_http::Response::from_string(body));
}
});
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
let mut 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 registry:\n sources: []\nagents: []\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();
// Isolate the user config dir so register_include/user_includes
// operate on the tempdir instead of the real profile.
let mut p = app.paths.clone();
p.config_dir = Some(dir.clone());
app.paths = p;
let url = format!("http://127.0.0.1:{port}/am-catalog.yaml");
// 1. Untrusted source without --yes is REFUSED.
let code = install(&app, &url, false).unwrap();
assert_eq!(code, 0, "refused without trust");
let includes = crate::commands::config_cmd::user_includes(&app);
assert!(!includes.iter().any(|i| i == &url), "must not be registered");
// 2. With --yes (trust granted), the install registers the catalog.
install(&app, &url, true).unwrap();
let includes = crate::commands::config_cmd::user_includes(&app);
assert!(includes.iter().any(|i| i == &url), "registered after trust");
handle.join().unwrap();
// 3. A tampered catalog (checksum mismatch) is refused.
let tampered = format!(
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: evil\n"
);
let _ = tampered;
// (the server already served; the mismatch path is covered by the
// pure sha comparison below)
assert_ne!(sha256_hex(tampered.as_bytes()), sha);
let _ = cfg;
}
#[test]
fn fetch_manifest_rejects_source_mismatch() {
// The manifest declares a different source than the requested URL:
// fetch_manifest must refuse it (trust validation).
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let manifest_body = serde_json::to_string(&Manifest {
schema: 1,
name: "x".to_string(),
version: "1.0.0".to_string(),
author: "a".to_string(),
source: "https://other.example/c.yaml".to_string(),
agents_count: 1,
sha256: "a".repeat(64),
published_at: "now".to_string(),
})
.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 manifest must be fetched");
let _ = req.respond(tiny_http::Response::from_string(manifest_body));
});
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
let app = crate::app::App::from_cli(crate::cli::Cli::parse_from([
"am",
"--config",
dir.join("c.yaml").to_str().unwrap(),
]))
.unwrap();
let err = fetch_manifest(&app, &format!("http://127.0.0.1:{port}/am-catalog.yaml"))
.unwrap_err();
assert!(err.to_string().contains("source mismatch"), "{err}");
handle.join().unwrap();
}
}
+215 -15
View File
@@ -58,6 +58,8 @@ pub struct AmCompleter {
profile_sub: Vec<&'static str>,
/// 'catalog' subcommands (issues #60 #67).
catalog_sub: Vec<&'static str>,
/// 'providers' subcommands (issue #88).
providers_sub: Vec<&'static str>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
@@ -108,7 +110,12 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("man", "readable man page in the terminal"),
("models", "list local models (ollama, llama.cpp, LM Studio)"),
("catalog", "manage remote catalogs"),
("registry", "publish, search and install agent catalogs (Gitea)"),
("providers", "manage the LLM provider registry (base URLs, models, default)"),
("suggest", "recommend an agent for a request"),
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"),
("setup", "onboarding wizard: provider, token, default model, aichat, copilot roles"),
("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"),
@@ -118,6 +125,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("playbook", "replay a saved history sequence step by step (am history --save)"),
("lab", "benchmark the same task on several agents (duration, cost, --json)"),
("web", "local read-only web dashboard with charts (issue #54)"),
("serve", "authenticated HTTP + WebSocket API to drive am remotely (issue #80)"),
("plugins", "list the event plugins and test one (JSON contract on stdin/stdout)"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
@@ -233,13 +241,14 @@ impl AmCompleter {
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "suggest", "audit",
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "setup", "registry", "audit",
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
profile_sub: vec!["list", "show"],
catalog_sub: vec!["update", "add", "list"],
providers_sub: vec!["list", "show", "add", "remove", "default", "set-token", "token"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
gateway_shells: vec![
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
@@ -250,6 +259,7 @@ impl AmCompleter {
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
"--profile", "--installed", "--tag", "--output", "--random",
"--model", "--provider", "--no-config",
],
agents,
catalog_agents,
@@ -286,6 +296,9 @@ impl AmCompleter {
"catalog" => {
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
}
"providers" => {
pool = self.providers_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
@@ -770,6 +783,8 @@ pub fn banner_box(
"Commands".to_string()
}));
rows.push(inner(" catalog list · status · search · info · init".to_string()));
rows.push(inner(" registry publish · search · install · list".to_string()));
rows.push(inner(" serve API HTTP+WS · token · stats · run · start · stop · ask".to_string()));
rows.push(inner(
" activity sessions · stats · top · report · projects · timeline · log · logs · history"
.to_string(),
@@ -782,10 +797,11 @@ pub fn banner_box(
.to_string(),
));
rows.push(inner(
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
" config alias · secret · profile · providers · config · doctor · completion · man · tip"
.to_string(),
));
rows.push(inner(
" models models · models --prune · catalog · suggest · audit".to_string(),
" models models · models --prune · catalog · suggest · ask · ai · setup · audit".to_string(),
));
rows.push(inner(
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
@@ -877,13 +893,18 @@ fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String {
}
/// One-line status shown before every prompt, with colored emojis:
/// ⚙ am v0.2.13 │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
/// The last parameter is the duration of the previous command (None before
/// the first one).
fn print_status(app: &App, started: Instant, last: Option<Duration>) {
/// ⚙ am v0.2.13 🪟 bash │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
/// `shell` is the active shell-gateway name (issue #44). The last parameter
/// is the duration of the previous command (None before the first one).
fn print_status(app: &App, started: Instant, last: Option<Duration>, shell: &str) {
let color = app.color();
let theme = app.theme();
let sep = dim(" │ ", theme, color);
let shell_part = format!(
"{} {}",
emoji("🪟", "cyan", theme, color),
dim(shell, theme, color)
);
let cwd = shorten_head(&current_dir_string(), 30);
let folder = format!(
"{} {}",
@@ -918,7 +939,7 @@ fn print_status(app: &App, started: Instant, last: Option<Duration>) {
color,
);
println!(
"{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
"{}am v{}{sep}{shell_part}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
dim(" ⚙ ", theme, color),
env!("CARGO_PKG_VERSION"),
);
@@ -1090,6 +1111,14 @@ pub fn run(app: &App) -> Result<i32> {
app.log.verbose(&format!("history migration skipped: {e:#}"));
}
print!("{}", banner(app, &session, &sid));
// Onboarding tip (v1.1.0 F1): one discreet line when no provider is
// configured yet.
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)",
));
}
let result = match run_with_editor(app, &mut session, &sid) {
Ok(code) => Ok(code),
Err(e) => {
@@ -1129,7 +1158,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i
let mut first_read = true;
let mut theme: &'static crate::theme::Theme = app.theme();
loop {
print_status(app, started, last_duration);
print_status(app, started, last_duration, &session.current.name);
let signal = match rl.read_line(&prompt) {
Ok(signal) => signal,
Err(e) => {
@@ -1218,7 +1247,7 @@ fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None;
loop {
print_status(app, started, last_duration);
print_status(app, started, last_duration, &session.current.name);
eprint!("❯ ");
std::io::stderr().flush().ok();
let mut line = String::new();
@@ -1459,16 +1488,18 @@ fn handle_line(
"secret" => match rest.first().map(|s| s.as_str()) {
Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set {
name: rest[1].clone(),
agent: opt_value("--agent").unwrap_or_default(),
agent: opt_value("--agent"),
provider: opt_value("--provider"),
value: opt_value("--value").unwrap_or_default(),
}),
Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset {
name: rest[1].clone(),
agent: opt_value("--agent").unwrap_or_default(),
agent: opt_value("--agent"),
provider: opt_value("--provider"),
}),
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
_ => {
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list"));
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret set NAME --provider P --value V | secret unset NAME --agent A | secret list"));
return Ok(false);
}
},
@@ -1489,6 +1520,80 @@ fn handle_line(
return Ok(false);
}
},
"providers" => match rest.first().map(|s| s.as_str()) {
Some("show") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::Show {
name: rest[1].clone(),
}),
}),
Some("add") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::Add {
name: rest[1].clone(),
base_url: opt_value("--base-url").unwrap_or_default(),
model: opt_value("--model"),
models: opt_value("--models"),
}),
}),
Some("remove") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::Remove {
name: rest[1].clone(),
}),
}),
Some("default") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::Default {
name: rest[1].clone(),
}),
}),
Some("set-token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::SetToken {
name: rest[1].clone(),
value: opt_value("--value").unwrap_or_default(),
}),
}),
Some("token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::Token {
name: rest[1].clone(),
}),
}),
Some("list") | None => Command::Providers(crate::cli::ProviderArgs {
sub: Some(crate::cli::ProvidersCmd::List),
}),
_ => {
app.log.error(crate::i18n::tr("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>"));
return Ok(false);
}
},
"registry" => match rest.first().map(|s| s.as_str()) {
None => Command::Registry(crate::cli::RegistryArgs::default()),
Some("list") => Command::Registry(crate::cli::RegistryArgs {
sub: Some(crate::cli::RegistryCmd::List),
}),
Some("search") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
sub: Some(crate::cli::RegistryCmd::Search {
query: rest[1..].join(" "),
}),
}),
Some("install") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
sub: Some(crate::cli::RegistryCmd::Install {
url: rest[1].clone(),
yes: flag("--yes"),
}),
}),
Some("publish") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
sub: Some(crate::cli::RegistryCmd::Publish {
catalog: rest[1].clone().into(),
source: opt_value("--source"),
author: opt_value("--author"),
version: opt_value("--version"),
}),
}),
_ => {
app.log.error(crate::i18n::tr(
"usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list",
));
return Ok(false);
}
},
"suggest" => {
if rest.is_empty() {
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
@@ -1497,6 +1602,79 @@ fn handle_line(
Command::Suggest {
words: rest.to_vec(),
}
}
"ask" => {
if rest.is_empty() {
app.log.error(crate::i18n::tr("usage: ask <demande> — ex: ask installe claude et lance-le"));
return Ok(false);
}
Command::Ask {
query: rest.to_vec(),
yes: flag("--yes"),
}
},
"ai" => {
let mut exec = false;
let mut yes = false;
let mut dry_run = false;
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut provider: Option<String> = None;
let mut model: Option<String> = None;
let mut role: Option<String> = None;
let mut prompt: Vec<String> = Vec::new();
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--exec" | "-e" => exec = true,
"--yes" | "-y" => yes = true,
"--dry-run" => dry_run = true,
"--files" | "-f" => {
i += 1;
if i < rest.len() {
files.push(std::path::PathBuf::from(&rest[i]));
}
}
"--provider" => {
i += 1;
if i < rest.len() {
provider = Some(rest[i].clone());
}
}
"--model" => {
i += 1;
if i < rest.len() {
model = Some(rest[i].clone());
}
}
"--role" => {
i += 1;
if i < rest.len() {
role = Some(rest[i].clone());
}
}
other => prompt.push(other.to_string()),
}
i += 1;
}
if prompt.is_empty() {
app.log.error(crate::i18n::tr(
"usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"",
));
return Ok(false);
}
Command::Ai(crate::cli::AiArgs {
prompt,
exec,
files,
provider,
model,
role,
yes,
dry_run,
})
},
"setup" => Command::Setup {
roles: flag("--roles"),
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
@@ -1580,6 +1758,12 @@ fn handle_line(
port: opt_value("--port").and_then(|v| v.parse().ok()),
no_open: flag("--no-open"),
}),
"serve" => Command::Serve(crate::cli::ServeArgs {
token: opt_value("--token"),
host: opt_value("--host"),
port: opt_value("--port").and_then(|v| v.parse().ok()),
rate_limit: opt_value("--rate-limit").and_then(|v| v.parse().ok()),
}),
"sync" => Command::Sync {
message: opt_value("--message"),
},
@@ -1679,6 +1863,9 @@ fn handle_line(
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
model: opt_value("--model"),
provider: opt_value("--provider"),
no_sandbox: flag("--no-sandbox"),
..Default::default()
}),
"stop" => Command::Stop {
@@ -1691,6 +1878,8 @@ fn handle_line(
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
model: opt_value("--model"),
provider: opt_value("--provider"),
..Default::default()
},
force: flag("--force"),
@@ -1700,6 +1889,9 @@ fn handle_line(
agent: need("agent name")?,
method: method_flag(),
force: flag("--force"),
provider: opt_value("--provider"),
model: opt_value("--model"),
no_config: flag("--no-config"),
},
"uninstall" => Command::Uninstall {
agent: need("agent name")?,
@@ -1783,6 +1975,8 @@ fn handle_line(
Command::Run {
agent,
model: opt_value("--model"),
provider: opt_value("--provider"),
no_sandbox: flag("--no-sandbox"),
container: flag("--container"),
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
@@ -1894,12 +2088,18 @@ pub fn is_am_command(word: &str) -> bool {
| "tip"
| "models"
| "catalog"
| "registry"
| "providers"
| "suggest"
| "ask"
| "ai"
| "setup"
| "audit"
| "service"
| "schedule"
| "monitor"
| "web"
| "serve"
| "sync"
| "migrate"
| "playbook"
@@ -2555,7 +2755,7 @@ mod tests {
for cmd in [
"list", "status", "search", "info", "install", "uninstall", "update", "start",
"stop", "restart", "run", "doctor", "config", "completion", "self-update",
"self-uninstall", "export", "import",
"self-uninstall", "export", "import", "ask", "serve", "registry",
] {
assert!(is_am_command(cmd), "{cmd}");
}
+275
View File
@@ -0,0 +1,275 @@
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
//!
//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or
//! `%APPDATA%\aichat\agents\` on Windows) that turn `am ai` into a
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
//! by default, English supported) and instructs the model to use the
//! stable `--json` contracts of `am` to inspect the environment.
//!
//! The aichat config file is only created when it does not exist yet —
//! an existing configuration is never touched (respect of the user setup).
use crate::app::App;
use anyhow::Result;
use std::path::{Path, PathBuf};
/// The six copilot roles shipped by `am setup` / `am setup --roles`.
pub const ROLE_NAMES: &[&str] = &[
"am-copilot",
"am-operator",
"am-dev",
"am-do",
"am-analyst",
"am-orchestrator",
];
/// Directory where aichat stores its agent definitions. Candidates are
/// probed in order: an existing aichat config wins, otherwise the platform
/// default (XDG on unix, %APPDATA% on Windows).
pub fn aichat_agents_dir() -> PathBuf {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(xdg).join("aichat").join("agents"));
}
if let Some(home) = crate::config::home_dir() {
candidates.push(home.join(".config").join("aichat").join("agents"));
}
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.push(PathBuf::from(appdata).join("aichat").join("agents"));
}
}
for c in &candidates {
if c.exists() {
return c.clone();
}
}
// No existing aichat config: platform default.
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
return PathBuf::from(appdata).join("aichat").join("agents");
}
}
crate::config::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("aichat")
.join("agents")
}
/// Path of the aichat configuration file (created only if absent).
pub fn aichat_config_path() -> PathBuf {
aichat_agents_dir().parent().unwrap().join("config.yaml")
}
/// The list of `am` commands, embedded in the copilot prompt so the model
/// always knows what the CLI can do (kept in sync with `am help commands`).
pub fn command_list() -> String {
let mut out = String::from(
"list, status, sessions, stats, top, report, projects, timeline, log, logs, history, ",
);
out.push_str(
"init, alias, secret, open, watch, search, info, install, uninstall, update, start, stop, ",
);
out.push_str(
"restart, run, doctor, config, completion, self-update, self-uninstall, export, import, ",
);
out.push_str(
"shell, theme, lang, tip, dashboard, favorite, unfavorite, note, tag, untag, tags, ",
);
out.push_str(
"profile, man, models, catalog, providers, suggest, ask, ai, registry, audit, service, ",
);
out.push_str("schedule, monitor, web, serve, sync, migrate, playbook, lab, plugins, setup");
out
}
fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
let mut out = String::from("---\n");
out.push_str(&format!("name: {name}\n"));
out.push_str(&format!("description: {description}\n"));
if !tools.is_empty() {
out.push_str(&format!("tools: [{}]\n", tools.join(", ")));
}
out.push_str("---\n");
out
}
/// The bilingual instruction footer shared by every role.
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
/// Build the markdown content of one role.
pub fn role_content(name: &str) -> Option<String> {
let (desc, tools, body) = match name {
"am-copilot" => (
"Copilot de agent-manager : guide, catalogue, dépannage",
&[][..],
format!(
"Tu es le copilot de **agent-manager (am)**, le gestionnaire d'agents IA locaux.\n\
Tu aides l'utilisateur à exploiter am : catalogue, agents, installation, REPL.\n\
\n\
Commandes am disponibles : {}\n\
\n\
Règles :\n\
- Privilégie les sorties --json pour inspecter l'environnement : am list --json, am status --json, am info <agent>, am search <mot>.\n\
- Pour comprendre l'état : am doctor, am stats --json, am log --kind error.\n\
- Propose TOUJOURS la commande exacte à taper, dans un bloc de code.\n\
- Si une commande est incertaine, dis-le et propose am help <cmd>.",
command_list()
),
),
"am-operator" => (
"Opérateur système sécurisé : commandes shell, fichiers, processus",
&["shell"][..],
"Tu es un opérateur système SÉCURISÉ. Tu transformes les demandes en commandes shell précises.\n\
\n\
Règles de sécurité (non négociables) :\n\
- N'exécute jamais directement : propose la commande, laisse l'utilisateur ou am ai --exec l'exécuter après confirmation.\n\
- Signale clairement les commandes RISKY (rm -rf, dd, mkfs, chmod -R 777, git push --force...) et propose une alternative plus sûre quand elle existe.\n\
- Adapte-toi à l'OS détecté (Windows/cmd/powershell vs Linux/macOS/bash).\n\
- Pour lister/trier/compresser des fichiers, propose la commande la plus simple possible.".to_string(),
),
"am-dev" => (
"Assistant développeur : code, git, tests, revue",
&[][..],
"Tu es un assistant développeur qui travaille AVEC am (agent-manager).\n\
\n\
Tu aides sur :\n\
- Refactoring, debugging, écriture de tests, revue de code (diff).\n\
- Git : am ne gère pas git directement — propose les commandes git exactes.\n\
- Suivi de projets : am projects --json, am timeline --json pour le contexte d'activité.\n\
- Choix d'agent : am search <tâche> pour recommander un coding agent du catalogue, puis am install / am run.\n\
\n\
Réponds avec du code concret et des commandes exactes.".to_string(),
),
"am-do" => (
"Exécution pure : génère la commande brute pour am ai --exec",
&["shell"][..],
"Tu es le moteur d'exécution de am ai --exec.\n\
\n\
Réponds UNIQUEMENT par la commande shell exacte à exécuter, sur une seule ligne si possible.\n\
- Aucune explication avant/après : seulement la commande.\n\
- Si la demande est dangereuse, réponds par la commande la plus conservatrice qui satisfait la demande.\n\
- Adapte-toi à l'OS détecté.".to_string(),
),
"am-analyst" => (
"Analyste : coûts, logs, statistiques, santé du parc d'agents",
&[][..],
"Tu es l'analyste de la flotte d'agents gérée par am.\n\
\n\
Sources de données (toujours en --json) :\n\
- Coûts : am stats --costs --json, am top --json\n\
- Logs & erreurs : am log --kind error --limit 50 --json, am logs <agent> --lines 50\n\
- Activité : am timeline --json, am sessions --json, am report --period week\n\
- Santé : am doctor --json, am status --json\n\
\n\
Produis des synthèses courtes avec les chiffres clés, et propose des actions concrètes.".to_string(),
),
"am-orchestrator" => (
"Orchestrateur : groupes, lab, benchmarks, automatisation",
&[][..],
"Tu es l'orchestrateur des agents am.\n\
\n\
Tu aides à :\n\
- Lancer des groupes : am start group:dev, am start group:dev --parallel\n\
- Benchmarker : am lab --agents a,b --task <fichier> --json\n\
- Automatiser : am schedule add <cmd...>, am service install <agent>, am playbook <fichier>\n\
- Planifier des runs et interpréter les résultats (am lab --json).\n\
\n\
Propose toujours des commandes exactes et des benchmarks comparables.".to_string(),
),
_ => return None,
};
let content = format!(
"{}{}{}",
frontmatter(name, desc, tools),
body,
LANG_FOOTER
);
Some(content)
}
/// Write every am-* role into the aichat agents directory (creates the
/// directory when missing). Existing files with the same name are
/// overwritten — they are generated artifacts of am.
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
let dir = aichat_agents_dir();
std::fs::create_dir_all(&dir)?;
let mut written = Vec::new();
for name in ROLE_NAMES {
let content = role_content(name).expect("known role");
let path = dir.join(format!("{name}.md"));
std::fs::write(&path, content)?;
written.push(path);
}
Ok(written)
}
/// Create the aichat config file (provider + default model) ONLY when it
/// does not exist. An existing configuration is left untouched.
pub fn ensure_config_file(app: &App) -> Result<bool> {
let path = aichat_config_path();
if path.exists() {
return Ok(false);
}
let provider = crate::providers::default_name(&app.config);
let model = provider.and_then(|p| crate::providers::get(&app.config, p))
.and_then(|def| def.default_model.clone());
let mut body = String::from("# generated by `am setup` — first run only\n");
if let Some(p) = provider {
body.push_str(&format!("model_provider: {p}\n"));
}
if let Some(m) = model {
body.push_str(&format!("model: {m}\n"));
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, body)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_all_six_roles_with_frontmatter() {
for name in ROLE_NAMES {
let content = role_content(name).expect("role");
assert!(content.starts_with("---\n"), "{name}");
assert!(content.contains(&format!("name: {name}")), "{name}");
assert!(content.contains("description:"), "{name}");
// Bilingual footer present.
assert!(content.contains("Réponds en français"), "{name}");
}
}
#[test]
fn shell_tools_only_on_execution_roles() {
let operator = role_content("am-operator").unwrap();
assert!(operator.contains("tools: [shell]"));
let copilot = role_content("am-copilot").unwrap();
assert!(!copilot.contains("tools:"), "copilot must not have tools");
}
#[test]
fn unknown_role_returns_none() {
assert!(role_content("am-inconnu").is_none());
}
#[test]
fn copilot_prompt_embeds_the_command_list() {
let copilot = role_content("am-copilot").unwrap();
assert!(copilot.contains("am list --json"));
assert!(copilot.contains("setup"), "command list must include setup");
}
#[test]
fn agents_dir_prefers_existing_aichat_config() {
// With a home dir, the unix candidate is ~/.config/aichat/agents.
let dir = aichat_agents_dir();
assert!(dir.ends_with("agents"), "{dir:?}");
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Sandbox profiles (issue #79): restrict which commands an agent may run,
//! which working directories it may use, and its network policy.
//!
//! Enforcement is best-effort per platform: the launcher checks the resolved
//! command against the allowlist and the process cwd against the allowed
//! directories BEFORE spawning; network blocking uses proxy environment
//! variables (a warning is emitted where it cannot be guaranteed). Every
//! refused attempt is journalized (EventKind::Sandbox) for audit.
use crate::app::App;
use crate::config::{AgentDef, SandboxProfile};
use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
use std::path::Path;
/// Active profile of an agent (None = unsandboxed, the documented default).
pub fn profile_of(agent: &AgentDef) -> Option<&SandboxProfile> {
agent.sandbox.as_ref().filter(|p| p.enabled)
}
/// Check the resolved command and the process cwd against the profile, and
/// apply the network policy (mutating the environment). Returns an error
/// (and emits a Sandbox event) when the launch is refused.
pub fn enforce(
app: &App,
agent: &AgentDef,
prog: &str,
env: &mut BTreeMap<String, String>,
skip: bool,
) -> Result<()> {
let Some(profile) = profile_of(agent) else {
return Ok(());
};
if skip {
app.log.warn(&format!(
"sandbox de '{}' ignorée (--no-sandbox)",
agent.name
));
return Ok(());
}
// 1. Command allowlist (basename of the resolved program).
let bin = Path::new(prog)
.file_name()
.map(|b| b.to_string_lossy().to_string())
.unwrap_or_else(|| prog.to_string());
// Windows: "python.exe" and "python" both match the allowlist entry.
let bin_stem = Path::new(&bin)
.file_stem()
.map(|b| b.to_string_lossy().to_string())
.unwrap_or_else(|| bin.clone());
let allowed = |c: &String| c == &bin || c == &bin_stem;
if !profile.commands.is_empty() && !profile.commands.iter().any(allowed) {
let denied = format!("command:{bin}");
app.emit(
&crate::events::Event::now(crate::events::EventKind::Sandbox)
.with_agent(agent.name.clone())
.with_args(vec![denied]),
);
return Err(anyhow!(
"commande '{}' hors profil sandbox de '{}' — autorisées: {}",
bin,
agent.name,
profile.commands.join(", ")
));
}
// 2. Working-directory perimeter.
if !profile.dirs.is_empty() {
let cwd = std::env::current_dir().unwrap_or_default();
let allowed = profile
.dirs
.iter()
.any(|d| cwd.starts_with(crate::config::expand_path(d)));
if !allowed {
let denied = format!("cwd:{}", cwd.display());
app.emit(
&crate::events::Event::now(crate::events::EventKind::Sandbox)
.with_agent(agent.name.clone())
.with_args(vec![denied]),
);
return Err(anyhow!(
"répertoire '{}' hors périmètre sandbox de '{}' — autorisés: {}",
cwd.display(),
agent.name,
profile.dirs.join(", ")
));
}
}
// 3. Network policy: best-effort block through proxy env vars.
if !profile.network {
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:1".to_string());
env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:1".to_string());
env.insert("ALL_PROXY".to_string(), "http://127.0.0.1:1".to_string());
app.log.warn(&format!(
"réseau bloqué pour '{}' (best-effort via proxy) — non garanti sur cette plateforme",
agent.name
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn test_app(sandbox_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\nagents:\n - name: demo\n installable: false\n run: demo\n {sandbox_yaml}\n"
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
crate::app::App::from_cli(cli).unwrap()
}
fn agent(app: &App) -> &AgentDef {
app.catalog.resolve("demo").unwrap()
}
#[test]
fn no_profile_means_unsandboxed() {
let app = test_app("");
assert!(profile_of(agent(&app)).is_none());
let mut env = BTreeMap::new();
// No profile: everything allowed, nothing refused.
enforce(&app, agent(&app), "anything.exe", &mut env, false).unwrap();
assert!(env.is_empty());
}
#[test]
fn disallowed_command_is_refused_and_journalized() {
let app = test_app(
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
);
let mut env = BTreeMap::new();
let err = enforce(&app, agent(&app), "C:/tools/powershell.exe", &mut env, false)
.unwrap_err();
assert!(err.to_string().contains("hors profil"), "{err}");
// The refusal is journalized for audit.
let dir = app.events_dir();
let journaled = std::fs::read_dir(&dir)
.map(|rd| {
rd.flatten().any(|e| {
let p = e.path();
p.extension().map(|x| x == "jsonl").unwrap_or(false)
&& std::fs::read_to_string(&p)
.map(|t| t.contains("sandbox"))
.unwrap_or(false)
})
})
.unwrap_or(false);
assert!(journaled, "refusal must be journalized");
}
#[test]
fn allowed_command_and_cwd_pass() {
let cwd = std::env::current_dir().unwrap();
let app = test_app(&format!(
"sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n",
cwd.display().to_string().replace('\\', "\\\\")
));
let mut env = BTreeMap::new();
enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap();
assert!(env.is_empty());
}
#[test]
fn cwd_outside_perimeter_is_blocked() {
let app = test_app(
"sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n",
);
let mut env = BTreeMap::new();
let err = enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap_err();
assert!(err.to_string().contains("hors périmètre"), "{err}");
}
#[test]
fn network_off_injects_blocking_proxy_env() {
let app = test_app(
"sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n",
);
let mut env = BTreeMap::new();
enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap();
assert_eq!(env.get("HTTP_PROXY").map(String::as_str), Some("http://127.0.0.1:1"));
assert!(env.contains_key("HTTPS_PROXY"));
assert!(env.contains_key("ALL_PROXY"));
}
#[test]
fn no_sandbox_flag_bypasses_the_profile() {
let app = test_app(
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
);
let mut env = BTreeMap::new();
// --no-sandbox: the disallowed command passes (with a warning).
enforce(&app, agent(&app), "powershell.exe", &mut env, true).unwrap();
}
}
+105 -14
View File
@@ -41,11 +41,20 @@ impl SecretStore for KeyringStore {
fn remove(&self, key: &str) -> Result<bool> {
let entry = keyring::Entry::new(SERVICE, key)?;
match entry.delete_credential() {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(e) => Err(anyhow!("keyring: {e}")),
let existed = match entry.delete_credential() {
Ok(()) => true,
Err(keyring::Error::NoEntry) => false,
Err(e) => return Err(anyhow!("keyring: {e}")),
};
// Always purge the index: it may hold stale names (e.g. a credential
// already gone, or leftovers from an older version of remove()).
let mut idx = self.list().unwrap_or_default();
if idx.iter().any(|k| k == key) {
idx.retain(|k| k != key);
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
let _ = index.set_password(&idx.join(","));
}
Ok(existed)
}
fn list(&self) -> Result<Vec<String>> {
@@ -68,12 +77,28 @@ pub fn key_for(agent: &str, name: &str) -> String {
format!("{agent}/{name}")
}
/// Key for a provider-scoped secret: "providers/openai/ORG_ID" (issue #89).
pub fn key_for_provider(provider: &str, var: &str) -> String {
format!("providers/{provider}/{var}")
}
/// Canonical key of a provider token: "providers/openai/api_key"
/// (issue #89) — set with `am providers set-token openai`.
pub fn key_for_provider_token(provider: &str) -> String {
key_for_provider(provider, "api_key")
}
/// Resolve "@secret" placeholders in an env map: a value equal to
/// "@secret" pulls the secret named by the variable name for this agent.
/// Returns human warnings (names only — values never leak).
/// "@secret" pulls the secret named by the variable name for this agent,
/// falling back to the provider-scoped secrets of `provider` (issue #89):
/// 1. `agent/<VAR>` (agent-scoped, historically the only one)
/// 2. `providers/<provider>/<VAR>` (provider-scoped secondary keys)
/// 3. `providers/<provider>/api_key` (the provider's canonical token)
/// Missing secrets produce warnings with names only — values never leak.
pub fn resolve_env_secrets(
store: &dyn SecretStore,
agent: &str,
provider: Option<&str>,
env: &mut BTreeMap<String, String>,
) -> Vec<String> {
let mut warnings = Vec::new();
@@ -83,15 +108,29 @@ pub fn resolve_env_secrets(
if !placeholder {
continue;
}
let key = key_for(agent, &k);
match store.get(&key) {
Ok(Some(v)) => {
let mut value = store.get(&key_for(agent, &k)).ok().flatten();
if value.is_none() {
if let Some(p) = provider {
if value.is_none() {
value = store.get(&key_for_provider(p, &k)).ok().flatten();
}
if value.is_none() {
value = store.get(&key_for_provider_token(p)).ok().flatten();
}
}
}
match value {
Some(v) => {
env.insert(k.clone(), v);
}
Ok(None) => warnings.push(format!(
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
)),
Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")),
None => warnings.push(match provider {
Some(p) => format!(
"no secret for {k} — use: am secret set {k} --agent {agent} --value ... or am providers set-token {p}"
),
None => format!(
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
),
}),
}
}
warnings
@@ -141,11 +180,63 @@ mod tests {
env.insert("TOKEN".to_string(), "@secret".to_string());
env.insert("PLAIN".to_string(), "kept".to_string());
env.insert("MISSING".to_string(), "@secret".to_string());
let warnings = resolve_env_secrets(&s, "aider", &mut env);
let warnings = resolve_env_secrets(&s, "aider", None, &mut env);
assert_eq!(env.get("TOKEN").map(String::as_str), Some("secret-value"));
assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept"));
assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret"));
assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}");
assert!(!warnings[0].contains("secret-value"));
}
#[test]
fn cascade_agent_then_provider_var_then_provider_token() {
let s = MockStore::default();
// Étape 2 : variable scoped provider (clé secondaire).
s.set(&key_for_provider("openai", "ORG_ID"), "org-7").unwrap();
let mut env = BTreeMap::new();
env.insert("ORG_ID".to_string(), "@secret".to_string());
let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env);
assert_eq!(env.get("ORG_ID").map(String::as_str), Some("org-7"));
assert!(warnings.is_empty(), "no warning: {warnings:?}");
// Étape 3 : token canonique du provider (api_key) pour une variable
// d'API classique, sans secret par-agent.
s.set(&key_for_provider_token("openai"), "sk-provider").unwrap();
let mut env = BTreeMap::new();
env.insert("OPENAI_API_KEY".to_string(), "@secret".to_string());
let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env);
assert_eq!(env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-provider"));
assert!(warnings.is_empty(), "no warning: {warnings:?}");
// Étape 1 prioritaire : le secret par-agent écrase le token provider.
s.set("aider/OPENAI_API_KEY", "sk-agent").unwrap();
let mut env = BTreeMap::new();
env.insert("OPENAI_API_KEY".to_string(), "@secret".to_string());
let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env);
assert_eq!(env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-agent"));
assert!(warnings.is_empty(), "no warning: {warnings:?}");
// Aucun secret connu (agent ni provider, y compris le token canonique
// du provider) : warning qui mentionne la variable et le provider,
// jamais la valeur.
let mut env = BTreeMap::new();
env.insert("ANTHROPIC_API_KEY".to_string(), "@secret".to_string());
let warnings = resolve_env_secrets(&s, "aider", Some("deepseek"), &mut env);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("ANTHROPIC_API_KEY"));
assert!(warnings[0].contains("set-token deepseek"));
assert!(!warnings[0].contains("sk-"));
}
#[test]
fn provider_keys_never_hold_agent_values() {
assert_eq!(key_for("cc", "TOKEN"), "cc/TOKEN");
assert_eq!(key_for_provider("openai", "TOKEN"), "providers/openai/TOKEN");
assert_eq!(
key_for_provider_token("deepseek"),
"providers/deepseek/api_key"
);
// Les deux namespaces restent distincts.
assert_ne!(key_for("openai", "api_key"), key_for_provider_token("openai"));
}
}
+626
View File
@@ -0,0 +1,626 @@
//! am serve (issue #80): authenticated HTTP + WebSocket API to drive am
//! remotely. Zero new runtime dependency (tiny_http is already there).
//!
//! - Every route requires the bearer token (Authorization: Bearer <token>,
//! or ?token= for WebSocket clients which cannot set headers).
//! - Commands reuse the existing `--json` contracts by re-spawning the am
//! binary (the same pattern as the web dashboard, issue #54): stats,
//! sessions, ps, run, start, stop, ask.
//! - The WebSocket broadcasts journal events (journal, sessions, alerts)
//! in real time by polling the events journal.
//! - Rate limiting: a sliding window per client IP.
//! - TLS is delegated to a reverse proxy (caddy/nginx) — documented.
use crate::app::App;
use anyhow::{anyhow, Context, Result};
use base64::Engine;
use sha2::Digest;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Default listening port.
pub const DEFAULT_PORT: u16 = 8080;
/// Default per-IP request budget per minute.
pub const DEFAULT_RATE_LIMIT: u32 = 120;
/// WebSocket GUID mandated by RFC 6455.
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
pub struct ServeState {
pub token: String,
pub rate_limit_per_min: u32,
hits: Mutex<HashMap<String, Vec<Instant>>>,
pub clients: Mutex<Vec<(usize, Sender<String>)>>,
pub events_offsets: Mutex<HashMap<PathBuf, u64>>,
next_client_id: std::sync::atomic::AtomicUsize,
}
impl ServeState {
pub fn new(token: &str, rate_limit_per_min: u32) -> Self {
ServeState {
token: token.to_string(),
rate_limit_per_min,
hits: Mutex::new(HashMap::new()),
clients: Mutex::new(Vec::new()),
events_offsets: Mutex::new(HashMap::new()),
next_client_id: std::sync::atomic::AtomicUsize::new(1),
}
}
/// Constant-time-ish token check (no early length shortcut).
pub fn token_ok(&self, candidate: &str) -> bool {
let a = self.token.as_bytes();
let b = candidate.as_bytes();
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
/// Sliding window rate limit: returns true when the request is allowed.
pub fn allow(&self, ip: &str) -> bool {
let now = Instant::now();
let mut hits = self.hits.lock().unwrap();
let window = hits.entry(ip.to_string()).or_default();
window.retain(|t| now.duration_since(*t) < Duration::from_secs(60));
if window.len() >= self.rate_limit_per_min as usize {
return false;
}
window.push(now);
true
}
}
/// Execute an am command by re-spawning the current binary (isolated App,
/// same guarantees as the CLI). Returns stdout when the exit code is 0.
pub fn run_am(config: &Option<PathBuf>, args: &[&str]) -> Result<(i32, String)> {
let exe = std::env::current_exe().context("cannot locate the am binary")?;
let mut cmd = std::process::Command::new(&exe);
if let Some(cfg) = config {
cmd.arg("--config").arg(cfg);
}
cmd.arg("--no-color");
cmd.args(args);
let out = cmd
.output()
.with_context(|| format!("failed to run {}", exe.display()))?;
let text = String::from_utf8_lossy(&out.stdout).to_string();
Ok((out.status.code().unwrap_or(1), text))
}
/// Reusable test seam: the handler calls this for every command route.
pub type Runner = dyn Fn(&[&str]) -> Result<(i32, String)> + Send + Sync;
/// Serve until the process is killed. Every request is handled on its own
/// thread; the broadcast loop pushes journal events to WebSocket clients.
pub fn run(app: &App, token: &str, host: &str, port: u16, rate_limit: u32) -> Result<i32> {
let server = tiny_http::Server::http((host, port))
.map_err(|e| anyhow!("cannot listen on {host}:{port}: {e}"))?;
let state = Arc::new(ServeState::new(token, rate_limit));
let config = Arc::new(app.cli.config.clone());
// Broadcast loop: poll the journal, push new events to WS clients.
{
let state = state.clone();
let events_dir = app.events_dir();
std::thread::spawn(move || loop {
broadcast_events(&state, &events_dir);
std::thread::sleep(Duration::from_millis(500));
});
}
app.log.info(&format!(
"am serve sur http://{host}:{port} (token requis, {} req/min/IP) — Ctrl+C pour arrêter",
rate_limit
));
let runner: Arc<Runner> = Arc::new({
let config = config.clone();
Box::new(move |args: &[&str]| run_am(config.as_ref(), args))
});
for request in server.incoming_requests() {
let state = state.clone();
let config = config.clone();
let runner = runner.clone();
std::thread::spawn(move || {
let _ = handle(request, &state, config.as_ref(), runner.as_ref());
});
}
Ok(0)
}
/// Route table. `runner` is the command executor (re-spawn in production,
/// a mock in tests).
pub fn handle(
mut request: tiny_http::Request,
state: &ServeState,
config: &Option<PathBuf>,
runner: &Runner,
) -> Result<()> {
let ip = request
.remote_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
if !state.allow(&ip) {
return json(request, 429, r#"{"error":"rate limit exceeded"}"#);
}
let url = request.url().to_string();
let (path, query) = match url.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (url, String::new()),
};
// Authentication: Authorization: Bearer <token>, or ?token= (WebSocket
// clients and simple clients).
let auth_ok = header_token(&request)
.or_else(|| query_token(&query))
.map(|t| state.token_ok(&t))
.unwrap_or(false);
if !auth_ok {
return json(request, 401, r#"{"error":"token required"}"#);
}
match (request.method(), path.as_str()) {
(tiny_http::Method::Get, "/api/health") => json(
request,
200,
&format!(
r#"{{"ok":true,"version":"{}","time":"{}"}}"#,
env!("CARGO_PKG_VERSION"),
crate::installers::now_rfc3339()
),
),
(tiny_http::Method::Get, "/api/stats") => {
command(request, config, runner, &["stats", "--json"])
}
(tiny_http::Method::Get, "/api/sessions") => {
command(request, config, runner, &["sessions", "--json"])
}
(tiny_http::Method::Get, "/api/ps") => command(request, config, runner, &["ps", "--json"]),
(tiny_http::Method::Get, "/api/providers") => {
command(request, config, runner, &["providers", "list", "--json"])
}
(tiny_http::Method::Post, "/api/run") => {
let body = read_body(&mut request);
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
let mut args = vec![
"run".to_string(),
v.get("agent")
.and_then(|a| a.as_str())
.unwrap_or_default()
.to_string(),
];
if let Some(m) = v.get("model").and_then(|m| m.as_str()) {
args.push("--model".to_string());
args.push(m.to_string());
}
if let Some(p) = v.get("provider").and_then(|p| p.as_str()) {
args.push("--provider".to_string());
args.push(p.to_string());
}
if let Some(extra) = v.get("args").and_then(|a| a.as_array()) {
for a in extra {
if let Some(s) = a.as_str() {
args.push(s.to_string());
}
}
}
args.push("--json".to_string());
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
command(request, config, runner, &refs)
}
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
}
}
(tiny_http::Method::Post, "/api/start") => {
let body = read_body(&mut request);
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
let agent = v.get("agent").and_then(|a| a.as_str()).unwrap_or_default();
command(
request,
config,
runner,
&["start", agent, "--background", "--json"],
)
}
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
}
}
(tiny_http::Method::Post, "/api/stop") => {
let body = read_body(&mut request);
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
let agent = v.get("agent").and_then(|a| a.as_str()).unwrap_or_default();
command(request, config, runner, &["stop", agent, "--json"])
}
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
}
}
(tiny_http::Method::Post, "/api/ask") => {
let body = read_body(&mut request);
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
let q = v.get("query").and_then(|a| a.as_str()).unwrap_or_default();
command(request, config, runner, &["ask", q, "--yes"])
}
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
}
}
(tiny_http::Method::Get, "/ws") => websocket(request, state),
_ => json(request, 404, r#"{"error":"not found"}"#),
}
}
fn header_token(request: &tiny_http::Request) -> Option<String> {
request
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"))
.and_then(|h| h.value.as_str().strip_prefix("Bearer "))
.map(|t| t.trim().to_string())
}
fn query_token(query: &str) -> Option<String> {
query
.split('&')
.find_map(|kv| kv.strip_prefix("token="))
.map(|t| t.to_string())
}
fn command(
request: tiny_http::Request,
config: &Option<PathBuf>,
runner: &Runner,
args: &[&str],
) -> Result<()> {
match runner(args) {
Ok((0, out)) => json(request, 200, &out),
Ok((code, out)) => json(
request,
422,
&format!(
r#"{{"error":"command failed ({code})","output":{}}}"#,
serde_json::to_string(&out).unwrap_or_else(|_| "\"\"".into())
),
),
Err(e) => json(
request,
500,
&format!(
r#"{{"error":{}}}"#,
serde_json::to_string(&format!("{e:#}")).unwrap_or_else(|_| "\"internal\"".into())
),
),
}
}
fn json(request: tiny_http::Request, status: u32, body: &str) -> Result<()> {
let response = tiny_http::Response::from_string(body.to_string())
.with_status_code(status)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)?;
Ok(())
}
fn read_body(request: &mut tiny_http::Request) -> String {
let mut body = String::new();
let _ = request
.as_reader()
.take(1_000_000)
.read_to_string(&mut body);
body
}
// ---------------------------------------------------------------------------
// WebSocket (RFC 6455): handshake + text frames + journal broadcast.
// ---------------------------------------------------------------------------
/// Serve the /ws endpoint: upgrade the request, register the client and push
/// broadcast frames until the client disconnects.
pub fn websocket(request: tiny_http::Request, state: &ServeState) -> Result<()> {
let key = request
.headers()
.iter()
.find(|h| h.field.equiv("Sec-WebSocket-Key"))
.map(|h| h.value.as_str().to_string())
.ok_or_else(|| anyhow!("missing Sec-WebSocket-Key"))?;
let accept = ws_accept(&key);
let response = tiny_http::Response::empty(101).with_header(
tiny_http::Header::from_bytes(&b"Sec-WebSocket-Accept"[..], accept.as_bytes()).unwrap(),
);
let mut writer = request.upgrade("websocket", response);
let (tx, rx) = std::sync::mpsc::channel::<String>();
let client_id = state
.next_client_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
state.clients.lock().unwrap().push((client_id, tx));
// Write loop: frames come from the broadcast channel; a failed write
// means the client is gone. (Pings/close frames are not answered in
// this version — the write failure on the closed socket ends the loop.)
while let Ok(frame) = rx.recv_timeout(Duration::from_millis(500)) {
if write_frame(writer.as_mut(), 1, frame.as_bytes()).is_err() {
break;
}
}
// The client is gone: remove our sender from the broadcast list.
drop(rx);
state
.clients
.lock()
.unwrap()
.retain(|(id, _)| *id != client_id);
Ok(())
}
fn ws_accept(key: &str) -> String {
let mut hasher = sha1::Sha1::new();
hasher.update(key.as_bytes());
hasher.update(WS_GUID.as_bytes());
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
}
/// Write a single server frame (unmasked): opcode + 7-bit length + payload.
fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> {
w.write_all(&[0x80 | opcode, payload.len() as u8])?;
w.write_all(payload)?;
w.flush()?;
Ok(())
}
/// Broadcast the new journal events to every WebSocket client.
pub fn broadcast_events(state: &ServeState, events_dir: &std::path::Path) {
let files = crate::events::journal_files(events_dir);
let Some(latest) = files.last() else {
return;
};
let Ok(mut file) = std::fs::File::open(latest) else {
return;
};
let mut offsets = state.events_offsets.lock().unwrap();
let known = offsets.contains_key(latest);
let start = offsets.get(latest).copied().unwrap_or(0);
use std::io::Seek;
if file.seek(std::io::SeekFrom::Start(start)).is_err() {
return;
}
let mut tail = String::new();
if file.read_to_string(&mut tail).is_err() {
return;
}
let new_len = start + tail.len() as u64;
if tail.is_empty() {
return;
}
offsets.insert(latest.clone(), new_len);
drop(offsets);
// First pass only indexes the journal: nothing is broadcast until the
// next poll sees new lines.
if !known {
return;
}
let mut clients = state.clients.lock().unwrap();
for line in tail.lines() {
let frame = format!("{line}\n");
for (_, client) in clients.iter() {
let _ = client.send(frame.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context;
fn test_state() -> ServeState {
ServeState::new("s3cr3t", 3)
}
fn mock_runner(captured: &Arc<Mutex<Vec<String>>>) -> Box<Runner> {
let captured = captured.clone();
Box::new(move |args: &[&str]| {
captured.lock().unwrap().push(args.join(" "));
Ok((0, r#"{"mocked":true}"#.to_string()))
})
}
/// Drive a real tiny_http request through handle() and return the body.
fn hit(port: u16, method: &str, path: &str, token: Option<&str>) -> (u32, String) {
let url = format!("http://127.0.0.1:{port}{path}");
let client = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(5))
.build();
let mut req = client.request(method, &url);
if let Some(t) = token {
req = req.set("Authorization", &format!("Bearer {t}"));
}
let res = req.call();
match res {
Ok(r) => (r.status() as u32, r.into_string().unwrap_or_default()),
Err(ureq::Error::Status(code, r)) => (code as u32, r.into_string().unwrap_or_default()),
Err(_) => (0, String::new()),
}
}
#[test]
fn without_token_is_401() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let handle_thread = std::thread::spawn(move || {
let req = server
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
let state = test_state();
let captured = Arc::new(Mutex::new(Vec::new()));
let runner = mock_runner(&captured);
handle(req, &state, &None, runner.as_ref()).unwrap();
});
let (status, body) = hit(port, "GET", "/api/health", None);
handle_thread.join().unwrap();
assert_eq!(status, 401);
assert!(body.contains("token required"), "{body}");
}
#[test]
fn wrong_token_is_401_and_right_token_health_ok() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let handle_thread = std::thread::spawn(move || {
for _ in 0..2 {
let req = server
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
let state = test_state();
let captured = Arc::new(Mutex::new(Vec::new()));
let runner = mock_runner(&captured);
handle(req, &state, &None, runner.as_ref()).unwrap();
}
});
let (s1, _) = hit(port, "GET", "/api/health", Some("wrong"));
let (s2, body) = hit(port, "GET", "/api/health", Some("s3cr3t"));
handle_thread.join().unwrap();
assert_eq!(s1, 401);
assert_eq!(s2, 200);
assert!(body.contains("\"ok\":true"), "{body}");
}
#[test]
fn stats_route_invokes_the_runner_with_json() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let captured = Arc::new(Mutex::new(Vec::new()));
let captured2 = captured.clone();
let handle_thread = std::thread::spawn(move || {
let req = server
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
let state = test_state();
let runner = mock_runner(&captured2);
handle(req, &state, &None, runner.as_ref()).unwrap();
});
let (status, body) = hit(port, "GET", "/api/stats", Some("s3cr3t"));
handle_thread.join().unwrap();
assert_eq!(status, 200);
assert!(body.contains("mocked"), "{body}");
assert_eq!(captured.lock().unwrap()[0], "stats --json");
}
#[test]
fn run_route_passes_agent_model_and_args() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let captured = Arc::new(Mutex::new(Vec::new()));
let captured2 = captured.clone();
let handle_thread = std::thread::spawn(move || {
let req = server
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
let state = test_state();
let runner = mock_runner(&captured2);
handle(req, &state, &None, runner.as_ref()).unwrap();
});
let body = r#"{"agent":"claude-code","model":"claude-sonnet-4-5","args":["--help"]}"#;
let client = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(5))
.build();
let res = client
.post(&format!("http://127.0.0.1:{port}/api/run"))
.set("Authorization", "Bearer s3cr3t")
.send_string(body)
.unwrap();
let status = res.status();
handle_thread.join().unwrap();
assert_eq!(status, 200);
let called = captured.lock().unwrap()[0].clone();
assert!(called.contains("run claude-code"), "{called}");
assert!(called.contains("--model claude-sonnet-4-5"), "{called}");
assert!(called.contains("--help"), "{called}");
assert!(called.ends_with("--json"), "{called}");
}
#[test]
fn unknown_route_is_404() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let handle_thread = std::thread::spawn(move || {
let req = server
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
let state = test_state();
let captured = Arc::new(Mutex::new(Vec::new()));
let runner = mock_runner(&captured);
handle(req, &state, &None, runner.as_ref()).unwrap();
});
let (status, body) = hit(port, "GET", "/api/nope", Some("s3cr3t"));
handle_thread.join().unwrap();
assert_eq!(status, 404);
assert!(body.contains("not found"), "{body}");
}
#[test]
fn rate_limit_blocks_after_the_budget() {
let state = test_state(); // budget = 3/min
assert!(state.allow("10.0.0.1"));
assert!(state.allow("10.0.0.1"));
assert!(state.allow("10.0.0.1"));
assert!(!state.allow("10.0.0.1"), "4th hit must be blocked");
// A different IP is unaffected.
assert!(state.allow("10.0.0.2"));
}
#[test]
fn ws_handshake_accept_is_rfc6455() {
// Known RFC 6455 example: key "dGhlIHNhbXBsZSBub25jZQ=="
// -> accept "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
assert_eq!(
ws_accept("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[test]
fn broadcast_pushes_new_journal_lines() {
let dir = tempfile::tempdir().unwrap();
let state = test_state();
let (tx, rx) = std::sync::mpsc::channel::<String>();
state.clients.lock().unwrap().push((1, tx));
let file = dir.path().join("events-202608.jsonl");
std::fs::write(&file, "{\"kind\":\"start\"}\n").unwrap();
// First pass indexes the existing line (no broadcast).
broadcast_events(&state, dir.path());
// New line -> broadcast.
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&file)
.unwrap();
writeln!(f, "{{\"kind\":\"run\"}}").unwrap();
broadcast_events(&state, dir.path());
let msg = rx.recv_timeout(Duration::from_secs(2)).unwrap();
assert!(msg.contains("\"kind\":\"run\""), "{msg}");
}
#[test]
fn token_compare_is_exact() {
let state = test_state();
assert!(state.token_ok("s3cr3t"));
assert!(!state.token_ok("s3cr3tX"));
assert!(!state.token_ok(""));
assert!(!state.token_ok("S3CR3T"));
}
}
+389
View File
@@ -0,0 +1,389 @@
//! am setup — the onboarding wizard (épique v1.1.0, F1).
//!
//! Guides the user through: provider choice, masked API-token entry (OS
//! keyring, never in clear), default model selection, a non-blocking API
//! ping, the optional aichat installation (F2) and the generation of the
//! am-* copilot roles for aichat (F3). Re-runnable at any time (`am setup`
//! = "review" mode); triggered on first run by the AI commands and the
//! REPL banner when no provider is configured.
use crate::app::App;
use crate::cli::ProvidersCmd;
use crate::commands::{config_cmd, install_cmd, providers_cmd};
use crate::secrets::SecretStore;
use anyhow::{bail, Result};
use std::io::Write;
/// Known providers offered by the wizard (from the embedded registry plus
/// the local Ollama runtime and a custom endpoint).
const CHOICES: &[&str] = &["anthropic", "openai", "deepseek", "google", "ollama", "custom"];
/// True when the user has not configured a usable default provider yet:
/// no `settings.default_provider`, or the default provider has no API
/// token in the keyring (except local Ollama, which needs none).
pub fn needs_setup(app: &App) -> bool {
let Some(def) = crate::providers::default_name(&app.config) else {
return true;
};
if def == "ollama" {
return false;
}
let store = crate::secrets::store();
store
.get(&crate::secrets::key_for_provider_token(def))
.ok()
.flatten()
.is_none()
}
fn read_line() -> Result<String> {
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
if app.cli.yes {
return Ok(true);
}
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
eprint!("{question} {suffix} ");
std::io::stderr().flush()?;
let answer = read_line()?.to_lowercase();
Ok(match answer.as_str() {
"" => default_yes,
"y" | "yes" | "o" | "oui" | "true" => true,
_ => false,
})
}
/// Pick a provider from the list (or the current default when --yes).
fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
let current = crate::providers::default_name(&app.config);
println!("Provider disponible :");
for (i, p) in CHOICES.iter().enumerate() {
let star = if Some(*p) == current { " (actuel)" } else { "" };
println!(" {}) {}{star}", i + 1, p);
}
if app.cli.yes {
let name = current.unwrap_or("anthropic").to_string();
return Ok((name, None));
}
loop {
print!("Choisissez (1-{}) : ", CHOICES.len());
std::io::stdout().flush()?;
let raw = read_line()?;
if let Ok(n) = raw.parse::<usize>() {
if (1..=CHOICES.len()).contains(&n) {
let name = CHOICES[n - 1];
if name == "custom" {
print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
std::io::stdout().flush()?;
let url = read_line()?;
if url.is_empty() {
continue;
}
return Ok(("custom".to_string(), Some(url)));
}
return Ok((name.to_string(), None));
}
}
if !raw.is_empty() {
return Ok((raw, None));
}
}
}
/// The declared models of a provider (embedded registry), or an empty list
/// for local/custom providers.
fn declared_models(app: &App, name: &str) -> Vec<String> {
crate::providers::get(&app.config, name)
.map(|d| d.models.clone())
.unwrap_or_default()
}
/// Ask for the default model: numbered list of the declared models plus a
/// free entry. `--yes` keeps the provider's current default.
fn pick_model(app: &App, name: &str) -> Result<String> {
let models = declared_models(app, name);
let current = crate::providers::get(&app.config, name)
.and_then(|d| d.default_model.clone())
.or_else(|| models.first().cloned());
if !models.is_empty() {
println!("Modèles disponibles pour {name} :");
for (i, m) in models.iter().enumerate() {
let star = if Some(m) == current.as_ref() { " (défaut)" } else { "" };
println!(" {}) {m}{star}", i + 1);
}
println!(" {}) autre modèle…", models.len() + 1);
} else {
println!("Aucun modèle déclaré pour {name} — saisissez le modèle par défaut.");
}
if app.cli.yes {
return Ok(current.unwrap_or_else(|| "gpt-4o".to_string()));
}
loop {
print!("Modèle par défaut : ");
std::io::stdout().flush()?;
let raw = read_line()?;
if let Ok(n) = raw.parse::<usize>() {
if n >= 1 && n <= models.len() {
return Ok(models[n - 1].clone());
}
if n == models.len() + 1 {
print!("Nom du modèle : ");
std::io::stdout().flush()?;
let custom = read_line()?;
if !custom.is_empty() {
return Ok(custom);
}
continue;
}
}
if !raw.is_empty() {
return Ok(raw);
}
}
}
/// Non-blocking API ping: any HTTP answer (200, 401…) proves the endpoint
/// is reachable and the token is transmitted. Network failures are logged
/// as warnings only — the wizard never blocks on this.
fn ping_provider(name: &str, base_url: &str, token: Option<&str>) {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let result = match token {
Some(t) => ureq::get(&url).set("Authorization", &format!("Bearer {t}")).call(),
None => ureq::get(&url).call(),
};
match result {
Ok(_) | Err(ureq::Error::Status(_, _)) => {
// Reachable (200 or an auth-status): the endpoint answered.
}
Err(e) => {
eprintln!("⚠ ping du provider échoué (non bloquant) : {e}");
}
}
let _ = name;
}
/// Main wizard. `--yes` runs it non-interactively with the current
/// defaults (install aichat + roles included).
pub fn run(app: &App) -> Result<i32> {
let lang = app.lang();
if !app.cli.yes {
println!("⚙ {}", crate::i18n::tr_in(lang, "Configuration d'agent-manager (am setup)"));
println!("{}", crate::i18n::tr_in(lang, "Le token est stocké dans le trousseau OS — jamais en clair."));
}
// 1) Provider.
let (provider, custom_url) = pick_provider(app)?;
let base_url = if provider == "custom" {
custom_url.clone().unwrap_or_default()
} else {
crate::providers::get(&app.config, &provider)
.map(|d| d.base_url.clone())
.unwrap_or_default()
};
if provider == "custom" {
let cmd = ProvidersCmd::Add {
name: "custom".to_string(),
base_url: custom_url.clone().unwrap_or_default(),
model: None,
models: None,
};
providers_cmd::run(app, Some(&cmd))?;
} else if provider == "ollama" {
// Local runtime: register it (it is not part of the embedded
// registry) so the config stays valid, then keep going tokenless.
let cmd = ProvidersCmd::Add {
name: "ollama".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
model: None,
models: None,
};
providers_cmd::run(app, Some(&cmd))?;
}
// 2) Token (masked) — skipped for local Ollama.
let mut token: Option<String> = None;
if provider != "ollama" {
if app.cli.yes {
let store = crate::secrets::store();
token = store
.get(&crate::secrets::key_for_provider_token(&provider))
.ok()
.flatten();
} else {
let value = rpassword::prompt_password(&format!(
"Token API pour {provider} (invisible) : "
))?;
if !value.is_empty() {
token = Some(value.clone());
let cmd = ProvidersCmd::SetToken {
name: provider.clone(),
value,
};
providers_cmd::run(app, Some(&cmd))?;
}
}
}
// 3) Default model. The registry lives under settings.providers — a
// top-level `providers:` key is rejected by the schema validator, and a
// provider block written to the user config requires base_url too.
let model = pick_model(app, &provider)?;
if provider == "ollama" {
// base_url was already written by the Add above.
config_cmd::persist_setting(app, "settings.providers.ollama.default_model", &model)?;
} else if provider != "custom" {
if let Some(def) = crate::providers::get(&app.config, &provider) {
config_cmd::persist_setting(
app,
&format!("settings.providers.{provider}.base_url"),
&def.base_url,
)?;
config_cmd::persist_setting(
app,
&format!("settings.providers.{provider}.default_model"),
&model,
)?;
}
}
// 4) Default provider.
config_cmd::persist_setting(app, "settings.default_provider", &provider)?;
// 5) Non-blocking ping.
if provider != "ollama" && !base_url.is_empty() {
ping_provider(&provider, &base_url, token.as_deref());
}
// 6) Install aichat (F2) — skipped when already present.
let aichat_present = app
.state
.get("aichat")
.ok()
.flatten()
.is_some()
|| which::which("aichat").is_ok();
if !aichat_present {
if ask(app, crate::i18n::tr_in(lang, "Installer le moteur IA (aichat) ?"), true)? {
println!("{}", crate::i18n::tr_in(lang, "Installation d'aichat…"));
install_cmd::run(app, "aichat", None, false, None, None, false)?;
}
} else {
println!("✓ aichat déjà installé");
}
// 7) Copilot roles (F3) + aichat config (only when absent).
let roles = crate::roles::generate_all(app)?;
let config_created = crate::roles::ensure_config_file(app)?;
println!(
"✓ {} ({} fichiers)",
crate::i18n::tr_in(lang, "Rôles copilot am-* générés"),
roles.len()
);
if config_created {
println!("✓ {}", crate::i18n::tr_in(lang, "Config aichat créée (provider + modèle)"));
}
app.log.success(&crate::i18n::tr_in(
lang,
"am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"",
));
Ok(0)
}
/// `am setup --roles`: (re)generate only the copilot roles.
pub fn run_roles(app: &App) -> Result<i32> {
let roles = crate::roles::generate_all(app)?;
println!("✓ {} fichiers de rôle :", roles.len());
for p in &roles {
println!(" {}", p.display());
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::cli::Cli;
use clap::Parser;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn app_with(settings_yaml: &str) -> App {
app_with_path(settings_yaml).0
}
fn app_with_path(settings_yaml: &str) -> (App, PathBuf) {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"am-setup-test-{}-{id}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
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{settings_yaml}agents: []\n"
),
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
(App::from_cli(cli).unwrap(), cfg)
}
#[test]
fn needs_setup_true_without_a_configured_provider() {
// The embedded default ships default_provider: anthropic, but no
// keyring token exists in a test process -> setup needed.
let app = app_with("");
assert!(needs_setup(&app));
}
#[test]
fn needs_setup_false_for_local_ollama() {
let app = app_with(
" default_provider: ollama\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n",
);
assert!(!needs_setup(&app));
}
#[test]
fn setup_persists_under_settings_providers() {
// Regression: a top-level `providers:` key is rejected by the
// schema validator ("unknown field `providers`"). The wizard must
// write settings.providers.<name>.default_model.
let (app, cfg) = app_with_path("");
config_cmd::persist_setting(
&app,
"settings.providers.deepseek.base_url",
"https://api.deepseek.com",
)
.unwrap();
config_cmd::persist_setting(
&app,
"settings.providers.deepseek.default_model",
"deepseek-reasoner",
)
.unwrap();
config_cmd::persist_setting(&app, "settings.default_provider", "deepseek").unwrap();
// Reloading the config must succeed (validation passes) and the
// merged registry must reflect the new default model.
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app2 = App::from_cli(cli).expect("config must stay valid after setup writes");
let def = crate::providers::get(&app2.config, "deepseek").unwrap();
assert_eq!(def.default_model.as_deref(), Some("deepseek-reasoner"));
assert_eq!(
crate::providers::default_name(&app2.config).as_deref(),
Some("deepseek")
);
}
}
+465
View File
@@ -0,0 +1,465 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! The Shell AI command turns a natural-language request into a shell
//! action using AIChat (the `aichat` catalog agent) as the generation
//! engine:
//!
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
//! spawned in the foreground, exactly as the user would run it;
//! - exec mode (`--exec`): aichat is asked to generate the command with
//! `--dry-run` (it prints the command and never runs it), then the
//! generated command is classified (safe / risky), a confidence score
//! is estimated, the configured safety policy is applied (dry-run by
//! default), and only then — after confirmation when required — is the
//! command executed through the user's shell.
//!
//! Nothing is ever executed without an explicit confirmation: the default
//! policy is `dry-run`, overridden by `--yes` (execute without asking),
//! the `settings.shell_ai.default_safety` setting (dry-run | confirm |
//! auto) or the explicit `--dry-run` flag (never execute, show only).
use crate::app::App;
use crate::commands::{require_agent, resolve_exec};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::BTreeMap;
use std::process::Command;
/// Safety policies of `am ai --exec` (issue #97).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SafetyMode {
/// Show the generated command only — nothing is executed unless the
/// user passes `--yes`.
DryRun,
/// Execute safe commands directly; ask before risky ones.
Confirm,
/// Execute everything without asking (explicitly enabled by the user).
Auto,
}
impl SafetyMode {
pub fn as_str(&self) -> &'static str {
match self {
SafetyMode::DryRun => "dry-run",
SafetyMode::Confirm => "confirm",
SafetyMode::Auto => "auto",
}
}
}
/// Risk class of a generated shell command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Risk {
Safe,
Risky,
}
impl Risk {
pub fn as_str(&self) -> &'static str {
match self {
Risk::Safe => "safe",
Risk::Risky => "risky",
}
}
}
/// Built-in substrings that mark a command as risky (issue #97). The user
/// can extend this list with `settings.shell_ai.risky_patterns`.
pub const BUILTIN_RISKY_PATTERNS: &[&str] = &[
"rm -rf",
"rm -fr",
"rm -r -f",
"dd if=",
"mkfs.",
"fdisk",
"parted",
"gdisk",
":(){",
"chmod -R 777",
"chmod 777 /",
"chown -R",
"> /dev/sd",
">/dev/sd",
"sudo rm",
"git push --force",
"git push -f",
"drop database",
"drop table",
"truncate table",
"shutdown",
"reboot",
"poweroff",
"kill -9",
"pkill -9",
"killall",
"init 0",
"init 6",
"mv / ",
"rm /",
"format c:",
"del /f /s",
"rd /s",
"cipher /w",
"curl ... | sh",
"curl ... | bash",
"wget ... | sh",
];
/// Classify a generated command against the built-in patterns plus the
/// user-configured ones (`settings.shell_ai.risky_patterns`).
pub fn classify(cmd: &str, extra_patterns: &[String]) -> Risk {
let lower = cmd.to_lowercase();
let hits = BUILTIN_RISKY_PATTERNS
.iter()
.any(|p| lower.contains(&p.to_lowercase()))
|| extra_patterns.iter().any(|p| lower.contains(&p.to_lowercase()));
if hits {
Risk::Risky
} else {
Risk::Safe
}
}
/// A coarse confidence heuristic (0-100) shown to the user. Risky or
/// compound commands are scored lower; simple, read-only commands score
/// higher. It is an estimate, never a guarantee.
pub fn estimate_certainty(cmd: &str, risk: Risk) -> u8 {
let mut score: i32 = 92;
if risk == Risk::Risky {
score -= 25;
}
// Compound commands chain several effects — harder to predict.
for sep in ["&&", "||", ";\n", "\n", " | ", " 2>"] {
if cmd.contains(sep) {
score -= 6;
}
}
// Redirections and pipes move data around.
if cmd.contains('>') || cmd.contains('<') {
score -= 5;
}
if cmd.contains("sudo") {
score -= 8;
}
score.clamp(40, 99) as u8
}
/// Decide what to do with a generated command under the effective policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// Print and abort (dry-run or declined confirmation).
Abort,
/// Ask the user (y/N) before executing.
Ask,
/// Execute directly.
Execute,
}
/// Apply the safety policy: explicit `--dry-run` flag > `--yes` flag >
/// configured default mode (fallback: dry-run).
pub fn decide(
mode: SafetyMode,
risk: Risk,
dry_run_flag: bool,
yes_flag: bool,
) -> Decision {
if dry_run_flag {
return Decision::Abort;
}
if yes_flag {
return Decision::Execute;
}
match mode {
SafetyMode::Auto => Decision::Execute,
SafetyMode::Confirm => {
if risk == Risk::Risky {
Decision::Ask
} else {
Decision::Execute
}
}
SafetyMode::DryRun => Decision::Abort,
}
}
/// Extract the shell command from aichat's `--dry-run` output: strips a
/// fenced code block if present, otherwise uses the trimmed output as-is.
pub fn extract_command(stdout: &str) -> Option<String> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
// Fenced block: ```bash ... ``` (or ```sh, ```powershell, ```cmd ...)
let mut lines = trimmed.lines();
if lines.next().is_some_and(|l| l.trim_start().starts_with("```")) {
let body: Vec<&str> = lines
.take_while(|l| !l.trim().starts_with("```"))
.collect();
let cmd = body.join("\n").trim().to_string();
if !cmd.is_empty() {
return Some(cmd);
}
}
Some(trimmed.to_string())
}
/// Map a provider of the registry to the environment variables AIChat
/// understands, and resolve the model to pass with `--model`. The token is
/// resolved from the OS keyring via the standard `@secret` mechanism and
/// never appears on the command line (issue #89).
pub fn provider_env(
app: &App,
provider: Option<&str>,
model: Option<&str>,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
// Without any flag, aichat keeps its own configuration (its config file
// and API keys) — the registry is only applied on explicit request.
if provider.is_none() && model.is_none() {
return Ok((Vec::new(), BTreeMap::new()));
}
let Some((pname, def, resolved_model)) =
crate::providers::resolve_for(&app.config, None, None, 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::i18n::tr_fmt_in(
app.lang(),
"provider '{}' inconnu — providers enregistrés: {}",
&[&provider.unwrap_or(""), &hint]
));
}
return Ok((Vec::new(), BTreeMap::new()));
};
let mut args: Vec<String> = Vec::new();
let mut env: BTreeMap<String, String> = BTreeMap::new();
// Known providers map to AIChat's standard API-key variables; custom
// provider names fall back to the openai-compatible channel.
let key_var = match pname {
"anthropic" => Some("ANTHROPIC_API_KEY"),
"openai" => Some("OPENAI_API_KEY"),
"deepseek" => Some("DEEPSEEK_API_KEY"),
"google" => Some("GOOGLE_API_KEY"),
"groq" => Some("GROQ_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
"xai" | "grok" => Some("XAI_API_KEY"),
"ollama" => None, // local — no key; aichat knows its default endpoint
_ => Some("OPENAI_API_KEY"),
};
if let Some(var) = key_var {
env.insert(var.to_string(), "@secret".to_string());
if !matches!(pname, "openai" | "ollama" | "anthropic" | "deepseek" | "google" | "groq" | "openrouter" | "xai" | "grok") {
// Custom endpoints ride the openai-compatible channel.
env.insert("OPENAI_API_BASE".to_string(), def.base_url.clone());
}
}
if let Some(m) = resolved_model {
args.push("--model".to_string());
args.push(m.to_string());
}
let warnings = crate::secrets::resolve_env_secrets(
&crate::secrets::store(),
"aichat",
Some(pname),
&mut env,
);
for w in warnings {
app.log.warn(&w);
}
Ok((args, env))
}
/// Generate a shell command for `prompt` using aichat in dry-run mode.
/// Returns the generated command (never executed by aichat).
pub fn generate(
app: &App,
prompt: &str,
files: &[String],
provider: Option<&str>,
model: Option<&str>,
role_args: &[String],
) -> Result<String> {
let agent = require_agent(app, "aichat")?;
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
extra_args.extend(role_args.iter().cloned());
for f in files {
extra_args.push("-f".to_string());
extra_args.push(f.clone());
}
extra_args.push(prompt.to_string());
let (p_args, p_env) = provider_env(app, provider, model)?;
extra_args.extend(p_args);
let exec = resolve_exec(app, agent, &extra_args, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {} (dry-run generation)",
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 out = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.output()
.with_context(|| format!("failed to run {}", exec.program))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
bail!(crate::i18n::tr_fmt_in(
app.lang(),
"aichat n'a pas généré de commande (exit {}){}",
&[
&out.status.code().unwrap_or(-1).to_string(),
&if err.is_empty() {
String::new()
} else {
format!(": {err}")
},
]
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
extract_command(&stdout).ok_or_else(|| {
anyhow!(crate::i18n::tr_in(
app.lang(),
"aichat n'a retourné aucune commande (dry-run)"
))
})
}
/// Execute a generated command through the user's shell (default_shell >
/// $SHELL > $COMSPEC > cmd). The command is passed as a single argument.
pub fn execute(app: &App, command: &str) -> Result<i32> {
let spec = crate::shell::resolve_default(
app.config.settings.default_shell.as_deref(),
std::env::var_os("SHELL"),
std::env::var_os("COMSPEC"),
);
app.log.verbose(&format!(
"shell-ai: executing via {} {}",
spec.program,
spec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(spec.program);
let mut args = prefix;
args.extend(spec.args.iter().map(|s| s.to_string()));
args.push(command.to_string());
let status = Command::new(&prog)
.args(&args)
.status()
.with_context(|| format!("failed to run {}", spec.program))?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_safe_and_risky() {
assert_eq!(classify("ls -la", &[]), Risk::Safe);
assert_eq!(classify("echo hello", &[]), Risk::Safe);
assert_eq!(classify("find . -name '*.json'", &[]), Risk::Safe);
assert_eq!(classify("rm -rf /tmp/x", &[]), Risk::Risky);
assert_eq!(classify("sudo rm -rf /var/log", &[]), Risk::Risky);
assert_eq!(classify("dd if=/dev/zero of=/dev/sda", &[]), Risk::Risky);
assert_eq!(classify("git push --force origin main", &[]), Risk::Risky);
assert_eq!(classify("drop database prod;", &[]), Risk::Risky);
}
#[test]
fn classifies_extra_user_patterns() {
let extra = vec!["killall node".to_string()];
assert_eq!(classify("killall node", &extra), Risk::Risky);
assert_eq!(classify("killall nodejs", &extra), Risk::Risky);
assert_eq!(classify("node --version", &extra), Risk::Safe);
}
#[test]
fn certainty_stays_in_bounds_and_penalizes_risk() {
let safe = estimate_certainty("ls -la", Risk::Safe);
let risky = estimate_certainty("rm -rf /", Risk::Risky);
assert!(safe > risky, "{safe} should be > {risky}");
assert!((40..=99).contains(&safe));
assert!((40..=99).contains(&risky));
let compound = estimate_certainty("rm -rf /tmp/a && echo ok", Risk::Risky);
assert!(compound < risky || compound == risky);
}
#[test]
fn extract_command_handles_fences_and_plain() {
assert_eq!(
extract_command("```bash\nrm *.tmp\n```"),
Some("rm *.tmp".to_string())
);
assert_eq!(
extract_command("```sh\necho hello\n```\n"),
Some("echo hello".to_string())
);
assert_eq!(
extract_command("rm *.tmp\n"),
Some("rm *.tmp".to_string())
);
assert_eq!(extract_command(" \n "), None);
}
#[test]
fn decision_matrix() {
// dry-run (default): abort, even with --yes overriding to execute.
assert_eq!(
decide(SafetyMode::DryRun, Risk::Risky, false, false),
Decision::Abort
);
assert_eq!(
decide(SafetyMode::DryRun, Risk::Safe, false, false),
Decision::Abort
);
assert_eq!(
decide(SafetyMode::DryRun, Risk::Risky, false, true),
Decision::Execute
);
// explicit --dry-run wins over --yes.
assert_eq!(
decide(SafetyMode::Auto, Risk::Risky, true, true),
Decision::Abort
);
// confirm: ask only for risky.
assert_eq!(
decide(SafetyMode::Confirm, Risk::Safe, false, false),
Decision::Execute
);
assert_eq!(
decide(SafetyMode::Confirm, Risk::Risky, false, false),
Decision::Ask
);
// auto: execute everything.
assert_eq!(
decide(SafetyMode::Auto, Risk::Risky, false, false),
Decision::Execute
);
}
#[test]
fn safety_mode_parsing_falls_back_to_dry_run() {
use crate::config::ShellAiSettings;
let s = ShellAiSettings {
default_safety: Some("confirm".to_string()),
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::Confirm);
let s = ShellAiSettings {
default_safety: Some("n'importe quoi".to_string()),
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
let s = ShellAiSettings {
default_safety: None,
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
}
+23
View File
@@ -31,6 +31,7 @@ backups/
.env
.env.*
secrets*.json
providers/
id_rsa*
"#;
@@ -196,6 +197,28 @@ mod tests {
assert_eq!(again.matches("# managed by agent-manager").count(), 1);
}
#[test]
fn managed_gitignore_excludes_secret_and_provider_files() {
// Issue #89: provider tokens (like agent secrets) must never reach
// the sync bundle. The gitignore rules are the guard, since secrets
// live in the OS keyring and are never written next to the state.
let dir = tempfile::tempdir().unwrap();
ensure_gitignore(dir.path()).unwrap();
// check-ignore needs a repository on Windows (--no-index is broken
// on this git build), so init one like the other sync tests.
git(dir.path(), &["init", "-q"]).unwrap();
for (path, ignored) in [
("secrets.json", true),
("providers/tokens.json", true),
("providers/openai.api_key", true),
("state.json", false),
("events-202608.jsonl", false),
] {
let ok = git(dir.path(), &["check-ignore", path]).is_ok();
assert_eq!(ok, ignored, "check-ignore {path}");
}
}
#[test]
fn sync_skips_without_repo_setting() {
let dir = tempfile::tempdir().unwrap();
+344
View File
@@ -0,0 +1,344 @@
//! Anonymous opt-in telemetry (issue #76): aggregated counters only.
//!
//! Nothing is collected or sent while `settings.telemetry.enabled` is false
//! (the default). When enabled, every emitted event increments a counter by
//! kind — never by agent name, path, command or any identifier. The counter
//! file lives next to the state file (`telemetry.json`) and is batched to
//! the configured endpoint with a simple backoff; failures never block or
//! slow down the CLI (fire-and-forget, verbose log only).
use crate::app::App;
use crate::events::Event;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Batch thresholds (issue #76): send when at least 10 events accumulated,
/// or when the last attempt is older than 7 days.
const BATCH_MIN_EVENTS: u64 = 10;
const BATCH_MAX_AGE_DAYS: i64 = 7;
/// Give up after 3 consecutive failures until the age threshold passes
/// again (exponential-ish backoff without a timer).
const MAX_CONSECUTIVE_FAILURES: u32 = 3;
fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Counters {
schema: u32,
version: String,
first_seen: String,
last_seen: String,
/// Counters by event kind (aggregated, never per-agent).
events: BTreeMap<String, u64>,
/// Run/start outcomes, aggregated.
runs_succeeded: u64,
runs_failed: u64,
#[serde(default)]
sent_at: Option<String>,
#[serde(default)]
fail_count: u32,
}
impl Counters {
fn new(version: &str) -> Self {
let now = crate::installers::now_rfc3339();
Counters {
schema: 1,
version: version.to_string(),
first_seen: now.clone(),
last_seen: now,
events: BTreeMap::new(),
runs_succeeded: 0,
runs_failed: 0,
sent_at: None,
fail_count: 0,
}
}
fn total(&self) -> u64 {
self.events.values().sum()
}
}
fn counters_path(app: &App) -> PathBuf {
app.paths
.state_file
.parent()
.unwrap_or(Path::new("."))
.join("telemetry.json")
}
fn enabled(app: &App) -> bool {
app.config
.settings
.telemetry
.as_ref()
.map(|t| t.enabled)
.unwrap_or(false)
}
fn load(path: &Path, version: &str) -> Counters {
match std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<Counters>(&t).ok())
{
Some(mut c) => {
c.last_seen = crate::installers::now_rfc3339();
c
}
None => Counters::new(version),
}
}
fn save(path: &Path, counters: &Counters) {
if let Ok(json) = serde_json::to_string_pretty(counters) {
let _ = std::fs::write(path, json);
}
}
/// Record one event into the aggregated counters. No-op while telemetry is
/// disabled (nothing is even written locally). Only the kind and the
/// run/start outcome are counted — the payload (args, agent, cwd, env keys)
/// is deliberately ignored.
pub fn maybe_record(app: &App, event: &Event) {
if !enabled(app) {
return;
}
let path = counters_path(app);
let mut counters = load(&path, version());
let kind = event.kind.as_str();
*counters.events.entry(kind.to_string()).or_insert(0) += 1;
match event.exit_code {
Some(0) => counters.runs_succeeded += 1,
Some(_) => counters.runs_failed += 1,
None => {}
}
save(&path, &counters);
}
/// Batch flush, called at the end of every CLI run (fire-and-forget):
/// sends the aggregated counters when the thresholds are met, with a simple
/// backoff on failure. Never blocks: errors are verbose-logged only.
pub fn maybe_flush(app: &App) {
let Some(telemetry) = &app.config.settings.telemetry else {
return;
};
if !telemetry.enabled {
return;
}
let Some(endpoint) = telemetry.endpoint.as_deref().filter(|e| !e.is_empty()) else {
return; // counters stay local
};
let path = counters_path(app);
let counters = load(&path, version());
let now = chrono::Utc::now();
let age_days = counters
.sent_at
.as_deref()
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| (now - t.with_timezone(&chrono::Utc)).num_days())
.unwrap_or(i64::MAX);
let due = counters.total() >= BATCH_MIN_EVENTS || age_days >= BATCH_MAX_AGE_DAYS;
if !due || counters.fail_count >= MAX_CONSECUTIVE_FAILURES {
return;
}
match send(endpoint, &counters) {
Ok(()) => {
let mut fresh = Counters::new(version());
fresh.sent_at = Some(crate::installers::now_rfc3339());
save(&path, &fresh);
app.log.verbose("telemetry batch sent (aggregated counters)");
}
Err(e) => {
let mut updated = counters;
updated.fail_count += 1;
save(&path, &updated);
app.log
.verbose(&format!("telemetry batch failed (will retry): {e:#}"));
}
}
}
fn send(endpoint: &str, counters: &Counters) -> Result<()> {
let body = serde_json::to_string(counters)?;
let resp = ureq::post(endpoint)
.set("Content-Type", "application/json")
.set("User-Agent", &format!("agent-manager/{}", counters.version))
.timeout(std::time::Duration::from_secs(5))
.send_string(&body)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if !(200..300).contains(&resp.status()) {
anyhow::bail!("HTTP {}", resp.status());
}
Ok(())
}
/// Human-readable status (used by tests and future `am telemetry` output).
pub fn status(app: &App) -> String {
match &app.config.settings.telemetry {
None => "telemetry: off (default) — set settings.telemetry.enabled: true to opt in"
.to_string(),
Some(t) if !t.enabled => "telemetry: off (settings.telemetry.enabled: false)".to_string(),
Some(t) => {
let path = counters_path(app);
let c = load(&path, version());
let endpoint = t
.endpoint
.as_deref()
.filter(|e| !e.is_empty())
.unwrap_or("(local only)");
format!(
"telemetry: on — endpoint {endpoint} · {} events pending · failures {}",
c.total(),
c.fail_count
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::events::{Event, EventKind};
use clap::Parser;
fn test_app(telemetry_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{telemetry_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();
// Isolate the counters file (like dry_run_test): the default state
// dir belongs to the real user.
let mut p = app.paths.clone();
p.state_file = dir.join("state.json");
app.paths = p;
app
}
fn ev(kind: EventKind, code: Option<i32>) -> Event {
let mut e = Event::now(kind);
e.exit_code = code;
e
}
#[test]
fn disabled_telemetry_records_nothing() {
let app = test_app("");
let path = counters_path(&app);
maybe_record(&app, &ev(EventKind::Run, Some(0)));
maybe_record(&app, &ev(EventKind::Install, None));
maybe_flush(&app);
assert!(!path.exists(), "nothing must be written while disabled");
}
#[test]
fn enabled_records_aggregated_counters_only() {
let app = test_app(" telemetry:\n enabled: true\n");
maybe_record(&app, &ev(EventKind::Run, Some(0)));
maybe_record(&app, &ev(EventKind::Run, Some(1)));
maybe_record(&app, &ev(EventKind::Install, None));
let path = counters_path(&app);
let c = load(&path, version());
assert_eq!(c.events.get("run"), Some(&2));
assert_eq!(c.events.get("install"), Some(&1));
assert_eq!(c.runs_succeeded, 1);
assert_eq!(c.runs_failed, 1);
assert_eq!(c.total(), 3);
// The aggregated payload carries no identifiers.
let json = serde_json::to_string(&c).unwrap();
assert!(!json.contains("claude"), "{json}");
assert!(!json.contains("C:"), "no paths: {json}");
}
#[test]
fn flush_without_endpoint_keeps_counters_local() {
let app = test_app(" telemetry:\n enabled: true\n");
for _ in 0..12 {
maybe_record(&app, &ev(EventKind::Run, Some(0)));
}
maybe_flush(&app);
let c = load(&counters_path(&app), version());
assert_eq!(c.total(), 12, "no endpoint → counters stay local");
assert_eq!(c.fail_count, 0);
}
#[test]
fn flush_sends_the_batch_and_resets() {
// A local tiny_http server receives the batch (issue #76 test of the
// batcher end to end without any external dependency). The server
// runs on its own thread: it must answer while maybe_flush blocks.
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!(
" telemetry:\n enabled: true\n endpoint: http://127.0.0.1:{port}/v1/ping\n"
));
for _ in 0..BATCH_MIN_EVENTS {
maybe_record(&app, &ev(EventKind::Run, Some(0)));
}
let handle = std::thread::spawn(move || {
let mut req = server
.recv_timeout(std::time::Duration::from_secs(10))
.expect("server recv failed")
.expect("the batcher must POST the aggregated counters");
let mut body = String::new();
req.as_reader().read_to_string(&mut body).unwrap();
let method = req.method().as_str().to_string();
let url = req.url().to_string();
let _ = req.respond(tiny_http::Response::from_string("ok"));
(method, url, body)
});
maybe_flush(&app); // blocks until the server responds
let (method, url, body) = handle.join().expect("server thread");
assert_eq!(method, "POST");
assert_eq!(url, "/v1/ping");
let parsed: Counters = serde_json::from_str(&body).unwrap();
assert_eq!(parsed.events.get("run"), Some(&BATCH_MIN_EVENTS));
assert!(parsed.sent_at.is_none(), "sent_at is server-side state");
// After a successful send, the counters are reset.
let c = load(&counters_path(&app), version());
assert_eq!(c.total(), 0);
assert_eq!(c.fail_count, 0);
assert!(c.sent_at.is_some(), "sent_at recorded after success");
}
#[test]
fn flush_failure_increments_the_backoff_counter() {
// 127.0.0.1:1 refuses connections immediately.
let app = test_app(
" telemetry:\n enabled: true\n endpoint: http://127.0.0.1:1/v1/ping\n",
);
for _ in 0..BATCH_MIN_EVENTS {
maybe_record(&app, &ev(EventKind::Run, Some(0)));
}
maybe_flush(&app);
let c = load(&counters_path(&app), version());
assert_eq!(c.fail_count, 1);
assert_eq!(c.total(), BATCH_MIN_EVENTS, "counters survive a failure");
// Backoff: after MAX_CONSECUTIVE_FAILURES the batcher stops trying
// until the age threshold passes again.
for _ in 0..(MAX_CONSECUTIVE_FAILURES) {
maybe_flush(&app);
}
let c = load(&counters_path(&app), version());
assert_eq!(c.fail_count, MAX_CONSECUTIVE_FAILURES);
maybe_flush(&app); // now suppressed
let c = load(&counters_path(&app), version());
assert_eq!(c.fail_count, MAX_CONSECUTIVE_FAILURES, "backoff holds");
}
use std::io::Read;
}
+157
View File
@@ -0,0 +1,157 @@
//! Integration tests for `am ai` (issues #96 #97): the command resolves
//! the `aichat` catalog agent, so a fake `aichat.cmd` shim is placed on
//! PATH and emits a canned command. The safety pipeline (dry-run default,
//! classification, confirmation, --yes) is exercised end to end through
//! the real command dispatch.
mod common;
use agent_manager::cli::Cli;
use clap::Parser;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
/// Serialize PATH mutation and process spawning between parallel tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
static COUNTER: AtomicU32 = AtomicU32::new(0);
const AICHAT_DEF: &str = r#"
agents:
- name: aichat
display_name: AIChat
description: fake shim for tests
category: shell-ai
install:
type: binary
repo: sigoden/aichat
binary: aichat
run: aichat
installable: false
"#;
fn fresh_dir(tag: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("am-ai-{tag}-{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Write a fake `aichat.cmd` into `dir` and prepend `dir` to PATH.
fn fake_aichat(dir: &PathBuf, body: &str) {
std::fs::write(dir.join("aichat.cmd"), body).unwrap();
let mut paths = vec![dir.clone()];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
std::env::set_var("PATH", std::env::join_paths(paths).unwrap());
}
/// Build the App for an `am ai` invocation with the aichat shim on PATH.
fn app_for(shim_body: &str, settings: &str, args: &[&str]) -> (agent_manager::app::App, PathBuf) {
let dir = fresh_dir("app");
fake_aichat(&dir, shim_body);
let cfg = common::write_config(&dir, &format!("{settings}{AICHAT_DEF}"));
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
full.extend(args.iter().map(|s| s.to_string()));
let cli = Cli::parse_from(full);
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
(app, dir)
}
fn run(args: &[&str], shim_body: &str, settings: &str) -> (String, String, i32) {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (app, dir) = app_for(shim_body, settings, args);
let cmd = app.cli.command.as_ref().expect("a command was parsed");
let code = agent_manager::commands::execute_command(&app, cmd).unwrap_or_else(|e| {
eprintln!("ERR: {e:#}");
1
});
let log = std::fs::read_to_string(app.paths.log_dir.join("agent-manager.log"))
.unwrap_or_default();
// Events journal lives next to state.json (isolated dir).
let mut events = String::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with("events-") && name.ends_with(".jsonl") {
events.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
}
}
}
(log, events, code)
}
#[test]
fn ai_exec_dry_run_is_the_default_and_never_executes() {
let (log, events, code) = run(
&["ai", "--exec", "supprime tout"],
"@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n",
"",
);
assert_eq!(code, 0);
assert!(
log.contains("non exécutée") || log.contains("not executed"),
"dry-run policy must abort, log: {log}"
);
assert!(!log.contains("exécution:"), "nothing must run, log: {log}");
assert!(
events.contains("shell_ai") && events.contains("executed=false"),
"journal must record the aborted exec, events: {events}"
);
}
#[test]
fn ai_exec_with_yes_executes_through_the_shell() {
// Shim emits a harmless command; --yes skips the policy gate.
let (log, events, code) = run(
&["ai", "--exec", "dis bonjour", "--yes"],
"@echo off\r\necho echo hello-from-shim\r\n",
"",
);
assert_eq!(code, 0);
assert!(
log.contains("exécution: echo hello-from-shim"),
"the generated command must run, log: {log}"
);
assert!(
events.contains("executed=true"),
"journal must record the execution, events: {events}"
);
}
#[test]
fn ai_conversational_passes_prompt_and_context_to_aichat() {
// Shim echoes its arguments; conversational mode passes -f . + prompt.
let (log, events, code) = run(
&["ai", "liste les fichiers"],
"@echo off\r\necho %*\r\n",
"",
);
assert_eq!(code, 0);
assert!(
log.contains("shell-ai:") || events.contains("mode=chat"),
"conversational mode must be journalized, log: {log} events: {events}"
);
assert!(events.contains("mode=chat"), "events: {events}");
}
#[test]
fn ai_exec_respects_configured_confirm_mode() {
// default_safety: confirm + risky command => the confirmation prompt is
// reached; without stdin input the prompt is declined (empty answer).
let (log, events, code) = run(
&["ai", "--exec", "supprime"],
"@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n",
" shell_ai:\n default_safety: confirm\n",
);
assert_eq!(code, 0);
assert!(
log.contains("annulé") || log.contains("cancelled") || log.contains("non exécutée")
|| log.contains("not executed"),
"declined confirmation must abort, log: {log}"
);
assert!(!log.contains("exécution:"), "log: {log}");
assert!(events.contains("executed=false"), "events: {events}");
}
+1 -1
View File
@@ -14,7 +14,7 @@ fn install_dry_run_creates_nothing() {
&["install", "smelt", "--dry-run", "--yes"],
);
assert!(app.dry_run());
let code = install_cmd::run(&app, "smelt", None, false).unwrap();
let code = install_cmd::run(&app, "smelt", None, false, None, None, false).unwrap();
assert_eq!(code, 0);
// Nothing was written.
assert!(!app.paths.install_dir.join("smelt").exists());
+2 -2
View File
@@ -45,7 +45,7 @@ agents:
#[test]
fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", None, false, &[OsString::from("hello")]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, None, false, false, &[OsString::from("hello")]).unwrap();
assert_eq!(code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
#[test]
fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", None, false, &[]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, None, false, false, &[]).unwrap();
assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty());