Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- AgentDef.detect : sonde PATH explicite, repli sur first_token sinon
- probe.rs : cache invalidé quand les tokens du catalogue changent
  (token_hash), plus besoin d'attendre un changement de PATH
- config.yaml : detect: lazycodex-ai / detect: nanobot
- tests : detect_token_prefers_explicit_binary + 432 tests verts
- v1.0.3, man pages régénérées
2026-08-20 10:53:59 -04:00
bruno 79e032b563 feat: Add Axe 13 (Shell AI) to roadmap 2026-08-20 10:45:10 -04:00
bruno d0f52eb7c0 fix: REPL — ask/serve/registry routées vers am au lieu du shell système
is_am_command() ne connaissait pas ask/serve/registry : le REPL les
envoyait au shell système ('command not found'). Ajout aux trois
points de contact (is_am_command, COMMAND_DESCRIPTIONS, banner) +
test élargi. Docs alignées sur v1.0 : README (ask/providers/registry/
serve), ROADMAP v2.3 (v0.7.0 + v1.0.0 livrés), ARCHITECTURE (arbre src
+ section copilote & plateforme), tips ask/serve/registry. Version 1.0.2,
man pages régénérées. CI release : cross-compile Linux musl + Windows
mingw sur un seul runner, macOS optionnel.
2026-08-20 10:22:21 -04:00
bruno 38d38d51e3 fix: install.sh acceptait seulement l'entrée 'am' dans l'archive de release
Le pipeline CI publie am-linux-x86_64.tar.gz avec une entrée nommée 'am-linux-x86_64' (pas 'am') : le test [ -f $tmpdir/am ] échouait et le script retombait sur cargo install depuis les sources alors que le binaire précompilé existe. Recherche flexible du premier fichier extrait (am | am-$platform-$arch | n'importe quel fichier). Vérifié : extraction réelle du tar.gz v1.0.1 → ELF statique trouvé.
2026-08-20 09:32:00 -04:00
43 changed files with 4089 additions and 144 deletions
+49 -51
View File
@@ -1,28 +1,30 @@
# Gitea Actions : pipeline de release (issue #42). # Gitea Actions : pipeline de release (issue #42).
# #
# Sur chaque tag v* : construit les binaires de toutes les plateformes, # Sur chaque tag v* : construit les binaires de toutes les plateformes.
# archive + publie la release Gitea avec les artefacts. Les installateurs # Le job principal 'release' cross-compile Linux + Windows depuis un seul
# (install.sh / install.ps1) et 'am self-update' consomment ces artefacts. # 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. # Les installateurs one-liner (install.sh / install.ps1) et 'am self-update'
# Un runner manquant laisse son job en attente ; le job linux couvre la # consomment les artefacts attaches a la release.
# majorite des cas (les artefacts windows/macos peuvent aussi etre produits
# par les scripts locaux documentes dans RELEASING.md).
# #
# Prérequis du runner : pouvoir tirer les images docker.io (rust:1.94-alpine, # Cross-compilation utilisee :
# ~300 Mo) et le secret GITEA_TOKEN (jeton avec droit 'write:release') # - Linux x86_64 + aarch64 : cargo-zigbuild + musl (binaires statiques)
# positionné sur le dépôt. La release existante est écrasée # - Windows x86_64 : mingw-w64 + x86_64-pc-windows-gnu
# (release_overwrite). #
# 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) : # 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 # en cas de 'no space left on device' : docker system prune -af --volumes
# sur l'HOTE du runner (pas une autre machine !) ; # sur l'HOTE du runner (pas une autre machine !) ;
# - les builds compilent dans CARGO_TARGET_DIR=/tmp (hors du volume # - les builds compilent dans CARGO_TARGET_DIR=/tmp (hors du volume
# workspace) avec l'incremental desactive, donc rien ne persiste ; # workspace) avec l'incremental desactive, donc rien ne persiste ;
# - le job linux utilise cargo-zigbuild : zig (~50 Mo) fournit le linker # - zig (~50 Mo) fournit le linker croise pour Linux.
# croise pour les DEUX cibles musl statiques dans une seule image legere
# (rust:1.94-alpine n'embarque pas de compilateur croise aarch64).
name: release name: release
on: on:
@@ -35,41 +37,56 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
linux: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: rust:1.94-alpine container: rust:1.94-bookworm
env: env:
CARGO_TARGET_DIR: /tmp/am-target CARGO_TARGET_DIR: /tmp/am-target
CARGO_INCREMENTAL: "0" CARGO_INCREMENTAL: "0"
steps: steps:
# actions/checkout (et son post-step) s'execute via node, absent de # 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) - 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 - uses: actions/checkout@v4
- name: Install build tools (zig + cargo-zigbuild) - name: Install build tools (zig + cargo-zigbuild + mingw)
run: | run: |
apk add --no-cache build-base zip curl apt-get update
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl 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 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 tar -xf /tmp/zig.tar.xz -C /opt
echo "/opt/zig-x86_64-linux-0.14.1" >> "$GITHUB_PATH" echo "/opt/zig-x86_64-linux-0.14.1" >> "$GITHUB_PATH"
cargo install cargo-zigbuild --locked 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) - 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 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 - name: Stage archives
run: | run: |
mkdir -p dist mkdir -p dist
cp "$CARGO_TARGET_DIR/x86_64-unknown-linux-musl/release/am" dist/am-linux-x86_64 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/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 cd dist
tar -czf am-linux-x86_64.tar.gz am-linux-x86_64 tar -czf am-linux-x86_64.tar.gz am-linux-x86_64
tar -czf am-linux-aarch64.tar.gz am-linux-aarch64 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 - name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
@@ -80,37 +97,16 @@ jobs:
files: | files: |
dist/am-linux-x86_64.tar.gz dist/am-linux-x86_64.tar.gz
dist/am-linux-aarch64.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/am-windows-x86_64.zip
dist/checksums.txt dist/checksums.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true release_overwrite: true
macos: 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 runs-on: macos-latest
env: env:
CARGO_TARGET_DIR: /tmp/am-target CARGO_TARGET_DIR: /tmp/am-target
@@ -132,7 +128,7 @@ jobs:
cd dist cd dist
tar -czf am-macos-x86_64.tar.gz am-macos-x86_64 tar -czf am-macos-x86_64.tar.gz am-macos-x86_64
tar -czf am-macos-aarch64.tar.gz am-macos-aarch64 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 - name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
@@ -140,6 +136,8 @@ jobs:
files: | files: |
dist/am-macos-x86_64.tar.gz dist/am-macos-x86_64.tar.gz
dist/am-macos-aarch64.tar.gz dist/am-macos-aarch64.tar.gz
dist/checksums.txt dist/checksums-macos.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} 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
+100 -4
View File
@@ -15,6 +15,7 @@
- 🚀 Les démarrer, arrêter, redémarrer en avant-plan ou en arrière-plan - 🚀 Les démarrer, arrêter, redémarrer en avant-plan ou en arrière-plan
- 🔍 Observer leur état, leurs logs, leurs statistiques d'utilisation - 🔍 Observer leur état, leurs logs, leurs statistiques d'utilisation
- 🛠️ Gérer dépendances, alias, groupes, profils, secrets, favoris et annotations - 🛠️ 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é. 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é"] APP["App<br/>contexte partagé"]
CLI_DEF["cli.rs<br/>définition des commandes"] CLI_DEF["cli.rs<br/>définition des commandes"]
CMD["commands/<br/>dispatch"] CMD["commands/<br/>dispatch"]
SHELL_AI["shell_ai.rs<br/>agent shell léger"]
end end
subgraph DATA["💾 Données persistantes"] subgraph DATA["💾 Données persistantes"]
@@ -62,7 +64,9 @@ flowchart TB
CLI --> APP CLI --> APP
REPL --> APP REPL --> APP
TUI --> APP TUI --> APP
WEB --> APP
APP --> CLI_DEF APP --> CLI_DEF
APP --> SHELL_AI
APP --> CONFIG APP --> CONFIG
APP --> CATALOG APP --> CATALOG
APP --> STATE APP --> STATE
@@ -76,6 +80,7 @@ flowchart TB
CMD --> DASH CMD --> DASH
CMD --> STATS CMD --> STATS
CMD --> SESSIONS CMD --> SESSIONS
SHELL_AI --> CMD
``` ```
--- ---
@@ -131,6 +136,7 @@ src/
├── app.rs 🧰 Contexte App (config, state, paths, logger, theme) ├── app.rs 🧰 Contexte App (config, state, paths, logger, theme)
├── config.rs ⚙️ Schéma YAML, chargement, fusion, validation ├── config.rs ⚙️ Schéma YAML, chargement, fusion, validation
├── catalog.rs 🔍 Index agents + recherche fuzzy + suggestions ├── catalog.rs 🔍 Index agents + recherche fuzzy + suggestions
├── catalog_remote.rs 🌐 Catalogues distants (fetch, cache, includes)
├── state.rs 💾 Base JSON des installations et annotations ├── state.rs 💾 Base JSON des installations et annotations
├── events.rs 📝 Journal d'événements JSONL ├── events.rs 📝 Journal d'événements JSONL
├── runner.rs 🏃 Trait d'exécution système / mock ├── runner.rs 🏃 Trait d'exécution système / mock
@@ -149,15 +155,34 @@ src/
├── tables.rs 📋 Rendu tabulaire ├── tables.rs 📋 Rendu tabulaire
├── theme.rs 🎨 Thèmes de couleur (REPL et tableaux) ├── theme.rs 🎨 Thèmes de couleur (REPL et tableaux)
├── web.rs 🌐 Serveur HTTP local (127.0.0.1) + API JSON + frontend embarqué ├── 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!) ├── frontend/ 📄 index.html — dashboard web (HTML5 + CSS + JS vanilla, include_str!)
├── history.rs ⏪ Historique des commandes ├── history.rs ⏪ Historique des commandes
├── sessions.rs 📅 Registre des sessions ├── sessions.rs 📅 Registre des sessions
├── projects.rs 🗂️️ Agrégation par projet ├── projects.rs 🗂️️ Agrégation par projet
├── hooks.rs 🪝 Hooks de cycle de vie ├── hooks.rs 🪝 Hooks de cycle de vie
├── secrets.rs 🔒 Gestion des secrets (keyring OS) ├── 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 ├── version.rs 🏷️ Informations de version
├── commands/ 📦 35 modules, un par commande ├── commands/ 📦 51 modules, un par commande
└── installers/ 📦 9 installateurs spécialisés └── installers/ 📦 8 installateurs spécialisés
``` ```
--- ---
@@ -205,7 +230,7 @@ settings:
aliases: aliases:
cc: claude-code cc: claude-code
gemini: gemini-cli gemini: antigravity-cli
groups: groups:
dev: [claude-code, aider, codex] dev: [claude-code, aider, codex]
@@ -435,6 +460,26 @@ flowchart TB
| `am version` | Version et build | | `am version` | Version et build |
| `am` (sans commande) | REPL interactif | | `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 ## 🌐 Options globales
@@ -461,7 +506,7 @@ Disponibles avant ou après la sous-commande.
```yaml ```yaml
aliases: aliases:
cc: claude-code cc: claude-code
gemini: gemini-cli gemini: antigravity-cli
``` ```
`am start cc` démarre `claude-code`. `am start cc` démarre `claude-code`.
@@ -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 ## 📚 Références
- `src/lib.rs:4` : documentation d'architecture du crate - `src/lib.rs:4` : documentation d'architecture du crate
Generated
+23 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "1.0.1" version = "1.1.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -36,6 +36,7 @@ dependencies = [
"nu-ansi-term", "nu-ansi-term",
"ratatui", "ratatui",
"reedline", "reedline",
"rpassword",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
@@ -1779,6 +1780,27 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" 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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "1.0.1" version = "1.1.9"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" license = "MIT"
@@ -38,6 +38,7 @@ toml = "0.8"
ureq = { version = "2", default-features = false, features = ["tls"] } ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2" wait-timeout = "0.2"
keyring = { version = "3", features = ["windows-native", "linux-native"] } keyring = { version = "3", features = ["windows-native", "linux-native"] }
rpassword = "7"
which = "7" which = "7"
zip = "0.6" zip = "0.6"
crossterm = "0.29" crossterm = "0.29"
+36 -4
View File
@@ -1,7 +1,7 @@
# 🚀 agent-manager — vos agents IA, gérés comme des apps # 🚀 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 **am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **72 agents connus**) coding (Claude Code, Codex, Aider, jcode, Prime Agent… **78 agents connus**)
en une ligne de commande — avec la gestion des dépendances (Node.js, Python, en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
Go, Rust…) prise en charge pour vous. Go, Rust…) prise en charge pour vous.
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
## ✨ Pourquoi am ? ## ✨ Pourquoi am ?
- 🗂️ **Catalogue de 72 agents prêts à l'emploi** — descriptions, méthodes - 🗂️ **Catalogue de 78 agents prêts à l'emploi** — descriptions, méthodes
d'installation, dépendances et commandes de démarrage déjà configurées. 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 - 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours 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. > 🔄 **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 ## 🎬 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 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 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 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 info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs | | am status [agent] | état, version, PID, logs |
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | | 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 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 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 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 --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours | | am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
@@ -127,7 +158,7 @@ Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AG
#### Cycle d'installation (`am install <agent>`) #### Cycle d'installation (`am install <agent>`)
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (72 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. 1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (78 agents) avec `~/.config/agent-manager/config.yaml` et un éventuel `./agent-manager.yaml`. Vos définitions remplacent les entrées par défaut du même nom.
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place. 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. 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 : 4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
@@ -207,6 +238,7 @@ Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AG
| am config show / edit / add / set | gère votre configuration (hooks on_start/on_stop/… dans settings.hooks) | | 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 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 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-update | met à jour am lui-même (si configuré) |
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) | | 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 | | am | shell interactif : bannière, passerelle système, Tab et historique |
@@ -351,7 +383,7 @@ supprimez aussi ces emplacements.)
1. --config <fichier> 1. --config <fichier>
2. ./agent-manager.yaml (répertoire courant) 2. ./agent-manager.yaml (répertoire courant)
3. ~/.config/agent-manager/config.yaml 3. ~/.config/agent-manager/config.yaml
4. catalogue embarqué (72 agents) 4. catalogue embarqué (78 agents)
Vos définitions **complètent ou surchargent** le catalogue par nom. Vos définitions **complètent ou surchargent** le catalogue par nom.
+352 -10
View File
@@ -1,6 +1,13 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil » # 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.2** · Statut : propositions, non engagées > **Version du document : 3.1** · Statut : phases 0 à 3 livrées
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.5
>
> 🧭 **Bilan du 2026-08-23** : tout le périmètre versionné (J0 → J3, v0.7.0,
> épique v1.1.0) est livré — **0 issue ouverte** sur Gitea. Il reste :
> **1 bug prioritaire** (hang de `cargo test` / `am doctor`, §15.4),
> 3 fonctionnalités P1 jamais découpées en issues (§15.2), des activités de
> release (§15.3) et des idées neuves proposées (§16).
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime) > 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, > ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
@@ -18,8 +25,136 @@
> (#47–#75), 250+ tests. am web, i18n, services, schedule, lab, sync, > (#47–#75), 250+ tests. am web, i18n, services, schedule, lab, sync,
> migrate, models, plugins d'événements… Binaires publiés sur Gitea. > migrate, models, plugins d'événements… Binaires publiés sur Gitea.
> >
> 🔨 **Phase v0.7.0 en cours (priorité P0)** : providers & configuration > ✅ **Phase v0.7.0 livrée (2026-08-19)** : 5/5 issues clôturées (#88–#92) —
> automatisée (épique #87) — livrée AVANT la Phase 3 (v1.0), voir section 7 bis. > registre de providers LLM (am providers), secrets partagés par provider,
> flags --provider/--model/--no-config à l'install, configuration
> post-install (bloc config:).
>
> ✅ **Phase 3 livrée en v1.0.0 (2026-08-20)** : 5/5 issues clôturées
> (#76–#80) — télémétrie opt-in, am registry, am ask, profils sandbox,
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
> archives, prompt REPL avec shell actif.
>
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect`
> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python),
> plus de faux « external » ; docs alignées.
>
> 🔧 **Maintenance v1.0.4 (2026-08-20)** : catalogue enrichi — 5 agents
> **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub
> Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI
> (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents.
>
> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée**
> (issues #96 #97) — langage naturel → action shell via AIChat : mode
> conversationnel (aichat -f <ctx>), mode --exec avec pipeline de sécurité
> (génération aichat --dry-run, classification safe/risky + certainité,
> politique dry-run par défaut configurable settings.shell_ai, confirmation
> y/N pour les commandes risky, exécution via le shell de l'utilisateur),
> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements
> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée.
>
> 🔧 **Maintenance v1.0.6 (2026-08-20)** : installateur Windows corrigé —
> `install.ps1` n'acceptait que l'entrée `am.exe` dans l'archive de release
> (la CI et les archives manuelles utilisent `am-windows-x86_64.exe`) →
> repli systématique sur `cargo install` au lieu du binaire précompilé.
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
> les deux entrées pour la compatibilité maximale. Chemin du message
> cargo install corrigé (`.cargo\bin\am.exe`).
>
> 🚀 **Épique v1.1.0 (2026-08-20)** : **Onboarding, Copilot & Rôles aichat**
> (Axe 14) — `am setup` (wizard provider + token keyring + modèle + ping),
> installation automatique d'aichat (wizard + `am ai` + installateurs),
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
>
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
> validateur : « unknown field `providers` »). Le registre vivant sous
> `settings.providers`, le wizard écrit désormais
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
> base_url quand un bloc provider est écrit). Test de régression
> `setup_persists_under_settings_providers`.
>
> 🔧 **Maintenance v1.1.2 (2026-08-20)** :
> - `am setup` : **openrouter** ajouté au choix des providers (base URL,
> token via OPENROUTER_API_KEY, 6 modèles déclarés proposés) ;
> le choix du modèle liste les modèles disponibles du provider.
> - `am ai` sans `--role` utilise **am-copilot par défaut** (régénération
> automatique des rôles si le fichier manque, dégradation douce).
> - Bug `am migrate --import` : la config importée était écrite dans le
> répertoire config GLOBAL (user_config_dir) au lieu du config actif de
> l'app — l'import écrasait la config réelle de la machine (et la suite
> de tests détruisait la config utilisateur à chaque `cargo test`).
> L'import respecte désormais `app.paths.config_dir` (--config/isolated).
>
> 🔧 **Maintenance v1.1.3 (2026-08-21)** : fix `resolve_exec` — pour un
> agent INSTALLÉ avec un binaire enregistré, le premier token de `run:`
> (ex. `picoclaw`) n'était jamais consommé : `am start <agent>` lançait
> `<bin> <token-run>` (picoclaw rejetait son propre nom comme
> sous-commande inconnue ; aichat recevait « aichat » comme prompt
> one-shot). Le token est désormais consommé dans la branche « binaire
> enregistré existant » (les branches non-installé/binaire manquant
> étaient déjà correctes). Test de régression
> `resolve_exec_installed_agent_does_not_leak_run_token`.
>
> 🔧 **Maintenance v1.1.4 (2026-08-21)** : hints de configuration
> post-install — nouveau champ `setup_hints` dans la définition d'un agent
> (config.yaml) : commandes de configuration manuelle affichées après
> l'install quand l'agent gère sa propre config (ex. picoclaw
> onboard/model/auth), avec substitution des placeholders `{model}`,
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
> premier agent du catalogue à les déclarer.
>
> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec
> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown
> `agents/<name>.md` ≤ 0.29 est remplacé par une définition
> `functions/agents/<name>/index.yaml` + un registre `functions/agents.txt`).
> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats
> (compatibilité totale) et `am ai` détecte la définition nouvelle génération
> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte
> par défaut (dossier courant) est filtré par am (`.git`, `target`,
> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB)
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
> n'appelle plus l'API, il écho la requête).
>
> 🔧 **Maintenance v1.1.6 (2026-08-24)** : correctifs de robustesse —
> timeout de 30 s sur les commandes capturées (doctor/runner ne pendent plus
> si un agent ne répond pas, pipes vidés dans des threads pour éviter les
> deadlocks), `is_running` Windows via l'API Win32 (OpenProcess /
> GetExitCodeProcess, plus fiable que tasklist), rejet des chemins unsafe
> (path traversal) dans `am migrate --import`, frames WebSocket RFC 6455
> avec vraies longueurs > 125 octets, boucle de traduction i18n de la page
> web corrigée (plus de boucle infinie), index du trousseau (keyring)
> sérialisé en JSON (clés contenant des virgules).
>
> 🔧 **Maintenance v1.1.7 (2026-09-01)** : Gemini CLI a cessé de servir les
> comptes Google gratuits/Pro/Ultra le 2026-06-18 — remplacé par **Antigravity
> CLI** (`agy`, binaire Go fermé, même harnais qu'Antigravity 2.0). Nouveau
> catalogue : entrée `antigravity-cli` (release GitHub
> google-antigravity/antigravity-cli), `gemini-cli` marqué déprécié
> (`installable: false`, clé API payante seulement), alias `gemini`/`agy`
> redirigés, groupe `mainstream` et gabarit web mis à jour. Import des
> extensions/skills Gemini : `agy plugin import gemini`.
>
> 🔧 **Maintenance v1.1.8 (2026-09-01)** : providers **QwenCloud** ajoutés au
> registre par défaut — `qwen-cloud` (pay-as-you-go, Model Studio/DashScope,
> dashscope-intl…/compatible-mode/v1) et `qwen-cloud-token-plan` (abonnement
> Credits, token-plan.ap-southeast-1…/compatible-mode/v1, modèles restreints
> dont qwen3.8-max). `qwen-code` pointe sur qwen-cloud par défaut (env_map
> OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer
> sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via
> `am providers set-token`).
>
> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses
> providers dans une constante codée en dur — les QwenCloud ajoutés au
> registre n'y apparaissaient pas. La liste est maintenant construite depuis
> le registre fusionné (embarqué + config utilisateur), ollama et custom en
> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne
> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion
> `--yes`).
--- ---
@@ -36,12 +171,14 @@
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi) 9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
10. [Risques & garde-fous](#10--risques--garde-fous) 10. [Risques & garde-fous](#10--risques--garde-fous)
11. [Par où commencer](#11--par-où-commencer) 11. [Par où commencer](#11--par-où-commencer)
12. [Bilan & reste à faire (2026-08-23)](#15--bilan--reste-à-faire-analyse-du-2026-08-23)
13. [Idées neuves (post-v1.1.5)](#16--idées-neuves-post-v115)
--- ---
## 1. 📌 Résumé exécutif ## 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, catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
processus pilotés, REPL avec passerelle shell, sauvegarde export/import. processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
@@ -103,7 +240,7 @@ quelques jours, à caler avant ou pendant la construction du journal.*
| Brique actuelle | Fichier | Limite aujourd'hui | | 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 | | 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** | | É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 | | Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
@@ -265,8 +402,8 @@ consultable, reprenable, archivable.
| Fonctionnalité | Effort | Phase | | Fonctionnalité | Effort | Phase |
|---|---|---| |---|---|---|
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 | | Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 | | ⬜ am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) — **non livré, sans issue** | S | P1 |
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 | | ⬜ am news — dernières releases des agents installés (API GitHub/Gitea, cache) — **non livré, sans issue** | M | P1 |
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 | | Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 | | am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 | | am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
@@ -305,7 +442,7 @@ consultable, reprenable, archivable.
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 | | Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
| Notes : am note claude-code "utiliser pour …" | S | P1 | | Notes : am note claude-code "utiliser pour …" | S | P1 |
| Tags personnels : am tag claude-code python | S | P1 | | Tags personnels : am tag claude-code python | S | P1 |
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 | | ⬜ Raccourcis de commandes : settings.shortcuts (i → install, s → start) — **non livré, sans issue** | S | P1 |
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 | | Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
| Thèmes couleurs du REPL | S | P2 | | Thèmes couleurs du REPL | S | P2 |
@@ -335,6 +472,206 @@ consultable, reprenable, archivable.
--- ---
### Axe 13 — ⚡ Shell AI & Action ⭐
🎯 Transformer une commande en langage naturel en une action shell exécutée par un agent léger, rapide et contextuel, sans démarrer un gros coding agent.
**Contexte :** `am` gère aujourd'hui ~77 agents IA, principalement des *coding agents*, des assistants et des outils SaaS. Les shell assistants sont maintenant au catalogue (AIChat, ShellGPT, Fabric, Shell AI, AI CLI — v1.0.4) : légers, rapides, conçus pour transformer une phrase en commande ou action sur le système de fichiers local (ex. *"traite les fichiers JSON du dossier courant pour extraire les clés uniques"*).
---
#### 13.1 Agents shell AI découverts (✅ tous ajoutés au catalogue — v1.0.4, issues #94 #95)
| Outil | Repo | Langage | Force | Maturité |
|---|---|---|---|---|
| ✅ **AIChat** | `sigoden/aichat` | Rust | Shell assistant natif, RAG, agents, fichiers/répertoires, 20+ providers | **10.4k stars**, très actif |
| ✅ **ShellGPT** | `TheR1D/shell_gpt` | Python | Génère/exécute commandes shell, code, docs | Très connu, mature |
| ✅ **Fabric** | `danielmiessler/fabric` | Go | Patterns AI (summarize, extract wisdom), CLI | Très populaire, orienté contenu/texte |
| ✅ **Shell AI** | `nishant9083/shell-ai` | TypeScript | Agent ReAct local via Ollama, MCP, filesystem tools | Plus récent, prometteur mais jeune |
| ✅ **AI CLI** | `kriserickson/ai-cli` | Go | Natural language → commandes shell avec safety policy (`risk` / `certainty`) | Petit, simple, moins connu |
---
#### 13.2 Recommandation : AIChat (`sigoden/aichat`)
**Choix privilégié pour la fonction Shell AI.**
| Avantage | Détails |
|---|---|
| **Léger & rapide** | Rust, binaire unique, cold start rapide, idéal pour RPi 4 |
| **Shell Assistant natif** | Génère et exécute des commandes shell à partir du langage naturel |
| **Multi-provider** | OpenAI, Claude, Gemini, DeepSeek, Groq, Ollama, OpenRouter… |
| **Passage de contexte** | `aichat -f .` injecte le dossier ou les fichiers dans le prompt |
| **Mode exécution** | `aichat -e "..."` exécute directement, mode conversationnel sinon |
| **Facilité d'intégration** | Release binaire GitHub, installable via `binary` dans le catalogue |
Exemples d'usage ciblés :
```bash
# Lister et traiter les fichiers JSON du dossier courant
aichat -f . -e "liste tous les fichiers JSON et extrait les clés uniques"
# Résumer un dossier de fichiers
aichat -f . "résume les données de ces fichiers JSON"
```
---
#### 13.3 Fonctionnalités prévues
| Fonctionnalité | Description | Effort | Phase |
|---|---|---|---|
| ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 |
| ✅ **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 |
| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 |
| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 |
| **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 |
---
#### 13.4 Risques & garde-fous
| Risque | Mitigation |
|---|---|
| Exécution automatique de commandes dangereuses | `--dry-run` par défaut, confirmation obligatoire avant toute commande `risky` |
| Coût API récurrent | Modèle cheap par défaut (`gpt-4.1-mini`, `gemini-flash`) ou Ollama local |
| Fuite de données sensibles | Support du mode local Ollama, pas d'envoi hors du provider configuré |
| Installation lente sur RPi | Utiliser l'installateur `binary` GitHub Releases plutôt que `cargo` |
---
#### 13.5 Idées sœurs (à intégrer ou lier à d'autres axes)
| Commande | Description | Axe lié |
|---|---|---|
| `am summarize <fichier/dossier>` | Résume un fichier ou un dossier via AIChat/Fabric | Axe 13 |
| `am explain <commande>` | Explique une commande shell avant exécution | Axe 13 |
| `am commit` | Génère un message de commit depuis `git diff` | Axe 13 / Axe 3 |
| `am review` | Review rapide d'un diff ou d'une PR | Axe 7 |
| `am translate <fichier> --to en` | Traduit un fichier markdown/doc | Axe 13 |
| `am ask-code "..."` | Pose une question sur le codebase sans lancer un gros agent | Axe 7 |
| `am doc <dossier>` | Génère un README/doc à partir du code | Axe 13 / Axe 7 |
| `am fix` | Corrige une commande shell qui a échoué | Axe 13 |
| `am note` | Extrait des action items d'un texte/réunion | Axe 10 |
| `am search-web "..."` | Recherche web rapide et réponse synthétisée | Axe 7 |
---
## 14. 🚀 Onboarding & Copilot aichat (épique v1.1.0)
> Objectif : réduire le **Time-To-First-Value à 0** — rendre `am` opérationnel
> pour l'IA locale dès la première seconde, et faire d'aichat le copilot de
> agent-manager. Livré en **v1.1.0 (2026-08-20)**.
| Fonctionnalité | Description | Statut |
|---|---|---|
| ✅ **Wizard d'onboarding (`am setup`)** | Provider (anthropic, openai, deepseek, google, ollama, custom), token masqué → trousseau OS (keyring, jamais en clair), modèle par défaut, ping API non-bloquant. Déclenché par `am ai`/`am ask`/banner REPL quand aucun provider n'est configuré (message discret, jamais bloquant), ré-exécutable à la main (mode revoir) | ✅ v1.1.0 |
| ✅ **Installation automatique d'aichat** | Étape du wizard « Installer le moteur IA (aichat) ? [Y/n] » → `am install aichat` (catalogue) ; `am ai` sans aichat propose l'installation ; install.ps1/install.sh enchaînent sur `am setup` si stdin interactif | ✅ v1.1.0 |
| ✅ **Rôles copilot aichat (am-*)** | 6 agents générés dans `~/.config/aichat/agents/` (ou %APPDATA%\\aichat\\agents) : am-copilot (guide & catalogue), am-operator (shell sécurisé), am-dev (code & git), am-do (exécution pure pour --exec), am-analyst (coûts/logs/stats), am-orchestrator (groupes/lab). Prompts bilingues (FR par défaut), privilégient les contrats `--json` | ✅ v1.1.0 |
| ✅ **`am ai --role <nom>`** | Rôle copilot passé à aichat (`--agent`) ; validation du fichier généré ; complétion REPL des rôles am-* ; config aichat créée (provider + modèle) seulement si absente | ✅ v1.1.0 |
| ✅ **Settings dédiés** | `settings.shell_ai` (default_safety dry-run/confirm/auto, risky_patterns) documenté dans config.yaml ; module `src/setup.rs` + `src/roles.rs` | ✅ v1.1.0 |
---
## 15. 🧭 Bilan & reste à faire (analyse du 2026-08-23)
> Analyse complète du dépôt le 2026-08-23 : code (v1.1.5, commit e637c06),
> historique git, tracker Gitea, artefacts de release.
### 15.1 ✅ Ce qui est livré
- **Tout le périmètre versionné** : J0/v0.3.0, J1/v0.4.1, J2/v0.6.0,
v0.7.0 (providers), J3/v1.0.0, épique v1.1.0 (onboarding/copilot) et les
maintenances v1.0.1 → v1.1.5.
- **0 issue ouverte** sur le tracker Gitea (issues #1 → #97 clôturées).
- `main` synchronisé avec `origin/main` ; Cargo.toml/Cargo.lock en
**v1.1.5** ; suite de tests : **455 tests passés, 0 échec** le
2026-08-23 (23 suites, hors `doctor_test` qui hang — §15.4).
- Vérifié présent dans le code : favoris/notes/tags (#39), dashboard,
monitor, web, serve, lab, sync, migrate, models, sandbox, registry,
ask, ai, setup + rôles copilot, self-update, services, schedule, i18n,
packaging, complétions, man pages.
### 15.2 ⬜ Fonctionnalités du roadmap NON livrées (jamais découpées en issues)
| Fonctionnalité | Axe | Effort | Constat dans le code |
|---|---|---|---|
| `am compare a b` — tableau côte à côte | Axe 7 | S | aucune commande `Compare` dans `src/cli.rs` |
| `am news` — dernières releases des agents installés | Axe 7 | M | aucun module, 0 occurrence dans `src/` |
| `settings.shortcuts` — raccourcis REPL (i → install…) | Axe 10 | S | 0 occurrence de `shortcuts` dans `src/` |
| Extensions sœurs Axe 13 (`am summarize`, `am explain`, `am fix`, …) | Axe 13 | M | absentes — voir §13.5 et l'idée N10 (§16) |
➡️ Suggestion : créer 3 issues Gitea (compare, news, shortcuts) en P1 —
petits efforts à forte valeur, seuls restes des jalons « cockpit ».
### 15.3 📦 Activités de release / opérations
| Activité | Détail |
|---|---|
| **Publier la release v1.1.5** | Le commit `e637c06` (fix aichat ≥ 0.30) est sur `main` **sans tag ni release Gitea** → installateurs et `am self-update` servent encore la v1.1.4. Taguer `v1.1.5` pour déclencher `.gitea/workflows/release.yml`. |
| **Compléter les assets v1.1.4** | La release v1.1.4 publiée ne contient **que** `am-windows-x86_64.zip` : binaires Linux (x86_64/aarch64) et macOS absents → les installateurs Linux/macOS retombent sur `cargo install --git`. |
| **Artéfacts `dist/` obsolètes** | Les archives de `dist/` datent de l'ère v0.6.0 (2026-08-19) ; laisser la CI régénérer celles de v1.1.5. |
| **Stash obsolète** | `stash@{0}` « WIP issue #39 : favoris, notes, tags » (2026-08-17 09:12) — la fonctionnalité a été livrée autrement dans v0.4.1 (champs présents dans `src/state.rs`) ; à purger (`git stash drop`) après confirmation. |
### 15.4 🐛 Bug découvert pendant l'analyse (2026-08-23) — à corriger en priorité
**`cargo test` se suspend** sur `doctor_test::report_contains_core_checks`
sur cette machine (reproduit 3 fois, dont une exécution > 20 min), et le
même hang peut frapper l'utilisateur final via `am doctor`.
- **Cause racine** : `SystemRunner::run` (`src/runner.rs`, branche
`capture`) attend `Command::output()` **sans timeout** et sans
protection contre l'héritage des pipes. `am doctor` sonde les outils du
PATH (`ollama ls`, `llama-server --version`, `git --version`…) ; quand
le processus fils engendre un petit-enfant qui conserve les handles
stdout/stderr (cas réel observé : `conhost.exe` alloué pour
`ollama.exe ls` lancé sans console), `output()` attend l'EOF des pipes
**indéfiniment**.
- **Reproduction** : `target\debug\deps\doctor_test-*.exe --nocapture
report_contains_core_checks` → hang > 40 s avec enfants `ollama.exe ls`
+ `conhost.exe` sous le processus de test ; l'ancien binaire de test du
2026-08-21 passait en 2,2 s (déclenchement du conhost non déterministe).
- **Périmètre touché** : tout ce qui passe par `SystemRunner::run` avec
capture — `am doctor` donc, et les sondes d'installation. (`am models`,
`am lab`, les plugins et la sonde externe utilisent déjà `wait_timeout`
et sont immunisés.)
- **Correctif proposé** : aligner `SystemRunner::run` sur le pattern déjà
éprouvé dans `src/models.rs` / `src/lab.rs` / `src/plugins.rs` : spawn
+ `wait_timeout` (30 s par défaut, `settings.probe_timeout_secs`) +
drain des pipes avant l'attente + kill de l'arbre à l'expiration — les
commentaires de `lab.rs` et `plugins.rs` documentent exactement ce
deadlock. À compléter par un test de régression (faux binaire qui
engendre un enfant garde-fou).
- **Contournement immédiat** : lancer les suites une à une, sans
`--test doctor_test`.
---
## 16. 💡 Idées neuves (post-v1.1.5)
> Ajoutées lors de l'analyse du 2026-08-23. Toutes ⬜ (proposées), non
> planifiées. Respectent l'ADN du projet : zéro dépendance runtime, local
> par défaut, contrats `--json` stables.
| # | Idée | Description | Axe | Effort | Phase |
|---|---|---|---|---|---|
| N1 | ⬜ **`am mcp` — serveur MCP** | Exposer am comme serveur *Model Context Protocol* : les agents (Claude Code, Codex, aichat…) découvrent et pilotent le parc via des outils MCP (`am__list`, `am__stats`, `am__sessions`, `am__start`…). am devient le hub de coopération entre agents. | nouveau | L | P3 |
| N2 | ⬜ **Budgets & alertes de coûts** | Budget par agent/projet avec alertes à seuils (80 % / 100 %) et blocage doux, branché sur le suivi des coûts (#49). | Axe 1 | M | P2 |
| N3 | ⬜ **Webhooks de notification** | Alertes `am watch` / `schedule` / `doctor --watch` vers un webhook (Discord, Slack, n8n, Gotify) en plus du `--notify` local. | Axe 6 | S | P2 |
| N4 | ⬜ **`am test <agent>` — smoke test** | Vérification normalisée post-install/post-update : le binaire répond, ping provider, tâche minimale — généralise le ping de `am setup` à tout le catalogue. | Axe 7 | M | P2 |
| N5 | ⬜ **`am diff` — comparaison de configs** | Diff de config/état entre deux machines ou deux points de sync ; complète `am sync` (#66) et `am migrate` (#68) pour le multi-machine. | Axe 9 | S | P2 |
| N6 | ⬜ **Transcripts de session** | `am sessions transcript <id>` : export Markdown lisible et partageable d'une session (commandes + sorties), complément de `sessions export` (#53). | Axe 4 | S | P2 |
| N7 | ⬜ **`am doctor --report`** | Rapport de diagnostic complet anonymisé (versions, doctor, événements récents) prêt à joindre à un bug report. | Axe 12 | S | P2 |
| N8 | ⬜ **Catalogue air-gapped** | `am catalog snapshot` : instantané hors-ligne complet du catalogue distant (définitions + binaires) pour machines sans internet. | Axe 7 | M | P3 |
| N9 | ⬜ **Export OpenTelemetry** | Export opt-in du journal `events.jsonl` vers OTLP/Grafana pour les équipes qui ont déjà une pile d'observabilité. | Axe 1 | M | P3 |
| N10 | ⬜ **Épique « Boîte à outils IA »** | Formaliser les commandes sœurs §13.5 sur les rôles copilot aichat (v1.1.0) : `am commit`, `am review`, `am explain`, `am fix`, `am summarize`, `am doc`, `am translate`, `am ask-code`, `am search-web`, `am note`. Une commande = un rôle + un raccourci de prompt, sécurité `--exec` héritée de `am ai`. | Axe 13 | L | P2 → P3 |
| N11 | ⬜ **Suite de tests 100 % hermétique** | `update_policy_test` (> 60 s/test) appelle réellement l'API GitHub via `installers::latest_version` ; prévoir un mode hors-ligne des sondes de version (flag test/offline ou mock), pour une suite rapide et déterministe partout. | Axe 12 | S | P1 |
---
## 8. 🚀 Jalons versionnés ## 8. 🚀 Jalons versionnés
| Jalon | Version | Contenu principal | Durée | Critère de sortie | | Jalon | Version | Contenu principal | Durée | Critère de sortie |
@@ -509,5 +846,10 @@ 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). *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), Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers,
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).* 2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe),
> v1.1.0 (onboarding/copilot) → v1.1.5 (aichat ≥ 0.30). Bilan & idées
> neuves ajoutés le 2026-08-23 (§15, §16).*
+149 -5
View File
@@ -64,6 +64,17 @@ settings:
# sources: # sources:
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml # - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
# author: bruno # 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 # 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é). # 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. # Les tentatives refusées sont journalisées (events sandbox) pour l'audit.
@@ -97,24 +108,52 @@ settings:
base_url: https://generativelanguage.googleapis.com/v1beta base_url: https://generativelanguage.googleapis.com/v1beta
default_model: gemini-2.5-pro default_model: gemini-2.5-pro
models: [gemini-2.5-pro, gemini-2.5-flash] models: [gemini-2.5-pro, gemini-2.5-flash]
# QwenCloud (Alibaba) — deux modes de facturation, deux providers :
# - qwen-cloud-token-plan : abonnement Credits (Personal/Team), clé
# dédiée Token Plan, modèles restreints (voir docs.qwencloud.com).
# Base URL internationale (ap-southeast-1) ; la variante Pékin
# token-plan.cn-beijing.maas.aliyuncs.com sert la Chine.
# - qwen-cloud : pay-as-you-go (clé sk- Model Studio / DashScope).
qwen-cloud-token-plan:
base_url: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
default_model: qwen3.8-max
models: [qwen3.8-max, qwen3.8-flash, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, deepseek-v4-pro, glm-5.2]
qwen-cloud:
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
default_model: qwen3.7-plus
models: [qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, qwen3-coder-plus, qwen3-coder-next, deepseek-v4-pro]
openrouter:
base_url: https://openrouter.ai/api/v1
default_model: deepseek/deepseek-chat-v3-0324
models:
- deepseek/deepseek-chat-v3-0324
- anthropic/claude-sonnet-4.5
- openai/gpt-4o
- google/gemini-2.5-pro
- meta-llama/llama-3.3-70b-instruct
- mistralai/mistral-large-2411
# --- Command aliases ---------------------------------------------------------- # --- Command aliases ----------------------------------------------------------
aliases: aliases:
cc: claude-code cc: claude-code
gemini: gemini-cli # Gemini CLI (npm @google/gemini-cli) ne sert plus les comptes Google
# gratuits/Pro/Ultra depuis le 2026-06-18 — remplacé par Antigravity CLI.
gemini: antigravity-cli
agy: antigravity-cli
gh-copilot: github-copilot gh-copilot: github-copilot
q: amazon-q q: amazon-q
droid: factory-droid droid: factory-droid
gjc: gajae-code gjc: gajae-code
tiny: tiny-agents tiny: tiny-agents
continue: continue-cli continue: continue-cli
ai: aichat
# --- Groups (start/stop/restart several agents together) ---------------------- # --- Groups (start/stop/restart several agents together) ----------------------
groups: groups:
dev: [claude-code, aider, codex] dev: [claude-code, aider, codex]
local: [atomic-agent, nanocoder, gptme, goose] local: [atomic-agent, nanocoder, gptme, goose]
claw: [openclaw, zeroclaw, picoclaw] claw: [openclaw, zeroclaw, picoclaw]
mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code] mainstream: [claude-code, opencode, antigravity-cli, codex, qwen-code]
# ============================================================================ # ============================================================================
# Agents # Agents
@@ -268,9 +307,26 @@ agents:
note: "projet en mode musée — maintenance 100 % automatisée" note: "projet en mode musée — maintenance 100 % automatisée"
tags: [rust, clean-room, fork] tags: [rust, clean-room, fork]
- name: antigravity-cli
display_name: "Antigravity CLI"
description: "Agent terminal officiel de Google (successseur de Gemini CLI, fermé, binaire Go). Même harnais d'agents qu'Antigravity 2.0 : multi-agents asynchrones, skills, hooks, plugins, MCP. Auth Google via keyring système, quota partagé avec l'app desktop (surveiller /usage)."
category: coding-agent
website: https://antigravity.google/cli
install:
type: binary
repo: google-antigravity/antigravity-cli
binary: agy
run: agy
provider: google
setup_hints:
- "agy (premier lancement : setup interactif + auth Google via le trousseau)"
- "agy plugin import gemini (importe extensions/skills de l'ancien Gemini CLI)"
tags: [google, mainstream, antigravity]
note: "Remplace Gemini CLI : le 2026-06-18, Gemini CLI a cessé de servir les comptes Google gratuits/Pro/Ultra. Import des extensions/skills Gemini : 'agy plugin import gemini' ; GEMINI.md et ~/.gemini/ restent lus (config native : ~/.gemini/antigravity-cli/, skills workspace : .agents/skills/, MCP : mcp_config.json avec clé 'serverUrl')."
- name: gemini-cli - name: gemini-cli
display_name: "Gemini CLI" display_name: "Gemini CLI (déprécié)"
description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche." description: "Ancien agent terminal Google (Apache-2.0). Ne sert plus les comptes Google gratuits/Pro/Ultra depuis le 2026-06-18 ; reste utilisable avec une clé API Gemini payante. Migrer vers antigravity-cli."
category: coding-agent category: coding-agent
website: https://github.com/google-gemini/gemini-cli website: https://github.com/google-gemini/gemini-cli
install: install:
@@ -279,7 +335,9 @@ agents:
dependencies: dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: gemini run: gemini
tags: [google, mainstream] installable: false
tags: [google, deprecated]
note: "déprécié — remplacé par Antigravity CLI ('am install antigravity-cli'), voir la note de cette entrée"
- name: codex - name: codex
display_name: "Codex CLI" display_name: "Codex CLI"
@@ -413,6 +471,15 @@ agents:
dependencies: dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: qwen-code run: qwen-code
provider: qwen-cloud
config:
env_map:
api_key: OPENAI_API_KEY
model: OPENAI_MODEL
base_url: OPENAI_BASE_URL
setup_hints:
- "am install qwen-code --provider qwen-cloud-token-plan (abonnement Credits Token Plan au lieu du pay-as-you-go)"
- "am providers set-token qwen-cloud-token-plan (clé dédiée Token Plan ; pay-as-you-go : clé sk- Model Studio)"
tags: [alibaba, qwen] tags: [alibaba, qwen]
- name: grok-build - name: grok-build
@@ -798,6 +865,9 @@ agents:
dependencies: dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: "npx lazycodex-ai" 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)" note: "s'intègre dans Codex (nécessite @openai/codex)"
tags: [harness, codex, plan] tags: [harness, codex, plan]
@@ -870,6 +940,71 @@ agents:
note: "étapes de build spécifiques au repo — consultez son README" note: "étapes de build spécifiques au repo — consultez son README"
tags: [simulator, orchestration] 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 # ----------------------------------------------------- Écosystème OpenClaw
- name: openclaw - name: openclaw
display_name: "OpenClaw" display_name: "OpenClaw"
@@ -895,6 +1030,9 @@ agents:
dependencies: dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" } - { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: "python -m nanobot" 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] tags: [python, lightweight]
- name: zeroclaw - name: zeroclaw
@@ -932,6 +1070,12 @@ agents:
repo: sipeed/picoclaw repo: sipeed/picoclaw
binary: picoclaw binary: picoclaw
run: picoclaw run: picoclaw
# PicoClaw gère sa config par sous-commandes (pas de fichier à écrire) :
# ces commandes sont affichées après l'install avec {model} substitué.
setup_hints:
- "picoclaw onboard"
- "picoclaw model {model}"
- "picoclaw auth login"
tags: [go, tiny] tags: [go, tiny]
- name: ironclaw - name: ironclaw
+11 -1
View File
@@ -24,7 +24,7 @@ function Install-FromSource {
Write-Host "Installation depuis les sources (cargo install --git $BaseUrl.git) ..." Write-Host "Installation depuis les sources (cargo install --git $BaseUrl.git) ..."
cargo install --git "$BaseUrl.git" --locked cargo install --git "$BaseUrl.git" --locked
if ($LASTEXITCODE -ne 0) { Fail "cargo install a echoue" } 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 ""
Write-Host "OK - am est installe dans $bin" Write-Host "OK - am est installe dans $bin"
Write-Host "Essayez : am list | am doctor (mise a jour : relancez ce script)" 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" $tmpDir = Join-Path $env:TEMP "am-$tag"
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $tmp -DestinationPath $tmpDir -Force 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" $exe = Join-Path $tmpDir "am.exe"
if (-not (Test-Path $exe)) { $exe = Join-Path $tmpDir "am-windows-x86_64.exe" }
if (Test-Path $exe) { if (Test-Path $exe) {
New-Item -ItemType Directory -Force -Path $binDir | Out-Null New-Item -ItemType Directory -Force -Path $binDir | Out-Null
Copy-Item $exe (Join-Path $binDir "am.exe") -Force Copy-Item $exe (Join-Path $binDir "am.exe") -Force
@@ -75,6 +78,13 @@ if (-not $InstallDir) {
$env:Path = "$binDir;$env:Path" $env:Path = "$binDir;$env:Path"
& (Join-Path $binDir "am.exe") --version & (Join-Path $binDir "am.exe") --version
Write-Host "" 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 "OK - 'am' est installe. Essayez :"
Write-Host " am list" Write-Host " am list"
Write-Host " am doctor" Write-Host " am doctor"
+26 -3
View File
@@ -22,6 +22,13 @@ case "$arch" in
*) fail "architecture non supportée: $arch" ;; *) fail "architecture non supportée: $arch" ;;
esac 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() { install_from_source() {
if ! command -v cargo >/dev/null 2>&1; then 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" 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="" installed=""
if [ -n "$tag" ] && command -v curl >/dev/null 2>&1; then 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" url="$BASE_URL/releases/download/$tag/$asset"
echo "Téléchargement de $url …" echo "Téléchargement de $url …"
if curl -fsSL --connect-timeout 20 -o "$tmpdir/am.tar.gz" "$url"; then 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 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" mkdir -p "$INSTALL_DIR"
cp "$tmpdir/am" "$INSTALL_DIR/am" cp "$bin" "$INSTALL_DIR/am"
chmod +x "$INSTALL_DIR/am" chmod +x "$INSTALL_DIR/am"
installed=1 installed=1
fi fi
@@ -77,6 +93,13 @@ esac
"$INSTALL_DIR/am" --version || true "$INSTALL_DIR/am" --version || true
echo "" 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 "OK — 'am' est installé. Essayez :"
echo " am list" echo " am list"
echo " am doctor" 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
+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
+8 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH am 1 "am 1.0.0" .TH am 1 "am 1.1.9"
.SH NAME .SH NAME
am \- agent\-manager (am) — manage local AI coding agents am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS .SH SYNOPSIS
@@ -133,6 +133,12 @@ Suggest agents matching a query, boosted by real usage (issue #61)
am\-ask(1) 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 Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.TP .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) am\-start(1)
Start an agent (foreground by default, or detached with \-\-background) Start an agent (foreground by default, or detached with \-\-background)
.TP .TP
@@ -244,4 +250,4 @@ Export the configuration and installation state (backup)
am\-import(1) am\-import(1)
Import a previously exported configuration and state Import a previously exported configuration and state
.SH VERSION .SH VERSION
v1.0.0 v1.1.9
+11 -2
View File
@@ -12,9 +12,18 @@ $zip = "dist/am-windows-x86_64.zip"
Remove-Item $zip -ErrorAction SilentlyContinue Remove-Item $zip -ErrorAction SilentlyContinue
Compress-Archive -Path target/release/am.exe -DestinationPath $zip 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 ""
Write-Host "Artefacts:" Write-Host "Artefacts:"
Get-ChildItem dist | ForEach-Object { Get-ChildItem dist -File | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() $hash = Get-Sha256Net $_.FullName
Write-Host (" {0,-32} {1} sha256:{2}" -f $_.Name, $_.Length, $hash) Write-Host (" {0,-32} {1} sha256:{2}" -f $_.Name, $_.Length, $hash)
} }
+298
View File
@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am web — Cockpit sombre (mockup)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
}
body {
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
}
.layout { display:flex; min-height:100vh; }
/* ---------- Sidebar ---------- */
.side {
width:225px; flex-shrink:0; background:var(--panel2);
border-right:1px solid var(--border); padding:18px 12px;
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
}
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
.logo-badge {
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
}
.logo b { font-size:16px; letter-spacing:.5px; }
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
.nav-item {
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
}
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
.nav-item .ico { width:18px; text-align:center; }
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; }
/* ---------- Main ---------- */
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
.topbar h1 { font-size:20px; font-weight:700; }
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
.top-actions { display:flex; align-items:center; gap:10px; }
.badge {
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
}
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
.badge.live::before { content:"● "; }
.btn {
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
font-weight:700; font-size:13px; cursor:pointer;
}
.btn:hover { filter:brightness(1.1); }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
/* ---------- KPI cards ---------- */
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
.kpi .delta { font-size:11.5px; margin-top:4px; }
.kpi .delta.up { color:var(--ok); } .kpi .delta.down { color:var(--err); }
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
/* ---------- Panels ---------- */
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
/* bars */
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); }
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
/* trend svg */
.trend-wrap { position:relative; }
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
/* tables */
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
tbody tr:hover { background:rgba(61,220,151,.05); }
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; }
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
.search:focus { outline:none; border-color:var(--accent); }
/* timeline */
.tl { list-style:none; }
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
.panel.hidden, .view { display:none; } .view.active { display:block; }
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
</style>
</head>
<body>
<div class="layout">
<aside class="side">
<div class="logo">
<div class="logo-badge">am</div>
<div><b>agent-manager</b><small>cockpit des agents</small></div>
</div>
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">Vue d'ensemble</span></div>
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">Statistiques</span></div>
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">Événements</span></div>
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">Projets</span></div>
<div class="side-foot">
<span><span class="dot"></span>v0.6.0 · lang FR ·<br>127.0.0.1:7878</span>
</div>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 id="view-title">Vue d'ensemble</h1>
<div class="sub" id="last-update">Dernière mise à jour : il y a 5 s</div>
</div>
<div class="top-actions">
<span class="badge live">serveur local</span>
<span class="badge">127.0.0.1:7878</span>
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
</div>
</div>
<!-- ===== OVERVIEW ===== -->
<section class="view active" id="overview">
<div class="kpis">
<div class="kpi"><span class="emoji">🤖</span><div class="label">Agents installés</div><div class="value">9</div><div class="delta up">▲ +1 cette semaine</div></div>
<div class="kpi"><span class="emoji">⚡</span><div class="label">En cours</div><div class="value">2</div><div class="delta up">● claude-code · codex</div></div>
<div class="kpi"><span class="emoji">⏱</span><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 % vs semaine préc.</div></div>
<div class="kpi"><span class="emoji">📜</span><div class="label">Événements (24 h)</div><div class="value">132</div><div class="delta down">▼ 8 % vs hier</div></div>
</div>
<div class="grid2">
<div class="panel">
<h3>Top agents — sessions (7 j)</h3>
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
</div>
<div class="panel">
<h3>Activité — sessions par jour (14 j)</h3>
<div class="trend-wrap">
<svg viewBox="0 0 480 150" width="100%" height="150">
<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3ddc97" stop-opacity=".35"/><stop offset="1" stop-color="#3ddc97" stop-opacity="0"/></linearGradient></defs>
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g)"/>
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3ddc97" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="480" cy="34" r="4" fill="#38bdf8"/>
</svg>
<div class="trend-axis"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
</div>
</div>
</div>
<div class="grid2">
<div class="panel">
<h3>Activité récente</h3>
<ul class="tl">
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">claude-code</span> terminé — session 3 h 12 min</span><span class="m">il y a 2 min</span></li>
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">codex</span> démarré — projet flowdeck</span><span class="m">il y a 14 min</span></li>
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">jcode</span> mis à jour 0.76.0 → 0.78.1</span><span class="m">il y a 1 h</span></li>
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">aider</span> échec — exit code 1</span><span class="m">il y a 3 h</span></li>
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">gemini-cli</span> terminé — session 42 min</span><span class="m">il y a 5 h</span></li>
</ul>
</div>
<div class="panel">
<h3>Sessions en cours</h3>
<table>
<thead><tr><th>Agent</th><th>Projet</th><th>Durée</th><th>Démarrée</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>agent-manager</td><td class="mono">3 h 12 min</td><td class="mono">14:26</td></tr>
<tr><td>codex</td><td>flowdeck</td><td class="mono">14 min</td><td class="mono">17:18</td></tr>
</tbody>
</table>
<div class="empty-note" id="running-empty" style="display:none">Aucune session en cours</div>
</div>
</div>
</section>
<!-- ===== STATS ===== -->
<section class="view" id="stats">
<div class="panel">
<h3>Statistiques par agent — période 30 j</h3>
<table>
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
<tr><td>opencode</td><td>9</td><td class="mono">4 h 40 m</td><td><span class="pill fail">55 %</span></td><td class="mono">0,90 €</td><td class="mono">il y a 2 j</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===== SESSIONS ===== -->
<section class="view" id="sessions">
<div class="panel">
<h3>Sessions — 7 derniers jours</h3>
<input class="search" id="sess-search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
<tbody id="sess-body">
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
<tr><td class="mono">20260817_183011_e2f3a4</td><td>jcode</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 22 min</td><td class="mono">19:35</td></tr>
</tbody>
</table>
<div class="empty-note" id="sess-empty" style="display:none">Aucune session ne correspond au filtre</div>
</div>
</section>
<!-- ===== EVENTS ===== -->
<section class="view" id="events">
<div class="panel">
<h3>Journal d'événements — aujourd'hui</h3>
<ul class="tl">
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> codex — projet flowdeck</span><span class="m">17:18</span></li>
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> claude-code — session 1 h 05 min</span><span class="m">16:40</span></li>
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> claude-code — projet agent-manager</span><span class="m">15:34</span></li>
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">run</span> aider — exit code 1 (fichier manquant)</span><span class="m">14:33</span></li>
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">update</span> jcode 0.76.0 → 0.78.1</span><span class="m">13:05</span></li>
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> gemini-cli — session 42 min</span><span class="m">12:12</span></li>
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> gemini-cli — projet imago</span><span class="m">11:30</span></li>
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">install</span> opencode 0.14.2 — méthode binary</span><span class="m">10:02</span></li>
</ul>
</div>
</section>
<!-- ===== PROJECTS ===== -->
<section class="view" id="projects">
<div class="kpis" style="grid-template-columns:repeat(3,1fr)">
<div class="kpi"><div class="label">Projets suivis</div><div class="value">4</div><div class="delta up">▲ 1 nouveau cette semaine</div></div>
<div class="kpi"><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 %</div></div>
<div class="kpi"><div class="label">Temps total</div><div class="value">96 h</div><div class="delta up">▲ +8 h vs semaine préc.</div></div>
</div>
<div class="panel">
<h3>Projets</h3>
<table>
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
</tbody>
</table>
</div>
</section>
</main>
</div>
<script>
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
const v = el.dataset.view;
document.getElementById(v).classList.add('active');
document.getElementById('view-title').textContent = titles[v];
});
});
function fakeRefresh(){
const t = new Date();
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + t.toLocaleTimeString('fr-FR');
}
function filterSessions(q){
q = q.toLowerCase();
let shown = 0;
document.querySelectorAll('#sess-body tr').forEach(tr => {
const ok = tr.textContent.toLowerCase().includes(q);
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
document.getElementById('sess-empty').style.display = shown ? 'none' : 'block';
}
</script>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am web — Admin clair (mockup)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#f6f7f9; --panel:#ffffff; --border:#e3e7ec; --fg:#1c2430; --muted:#64748b;
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --err:#dc2626; --purple:#7c3aed;
}
body {
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
}
/* header bar */
.head {
background:var(--panel); border-bottom:1px solid var(--border);
padding:0 28px; height:56px; display:flex; align-items:center; gap:24px; position:sticky; top:0; z-index:5;
}
.head .brand { font-weight:800; font-size:16px; display:flex; align-items:center; gap:9px; }
.brand .sq { width:26px; height:26px; border-radius:7px; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; }
.tabs { display:flex; gap:4px; height:100%; }
.tab { display:flex; align-items:center; padding:0 16px; color:var(--muted); cursor:pointer; font-size:13.5px; font-weight:500; border-bottom:2px solid transparent; }
.tab:hover { color:var(--fg); }
.tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.head .spacer { flex:1; }
.hbadge { font-size:11.5px; color:var(--muted); background:var(--bg); border:1px solid var(--border); padding:4px 10px; border-radius:99px; font-family:ui-monospace,Consolas,monospace; }
.hbadge.ok { color:var(--ok); }
.btn { background:var(--accent); color:#fff; border:none; border-radius:7px; padding:7px 14px; font-weight:600; font-size:13px; cursor:pointer; }
.btn:hover { background:#1d4ed8; }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
.btn.ghost:hover { color:var(--fg); }
.wrap { max-width:1150px; margin:26px auto; padding:0 28px; }
.page-title { font-size:19px; font-weight:700; margin-bottom:4px; }
.page-sub { color:var(--muted); font-size:12.5px; margin-bottom:20px; }
.cards { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.card { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:15px 17px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
.card .l { color:var(--muted); font-size:11.5px; text-transform:uppercase; letter-spacing:.5px; }
.card .v { font-size:26px; font-weight:800; margin-top:5px; color:#111827; }
.card .d { font-size:11.5px; margin-top:3px; } .card .d.up{color:var(--ok);} .card .d.down{color:var(--err);}
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px 18px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
.panel h3 { font-size:12.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--muted); margin-bottom:14px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.4px; padding:7px 10px; border-bottom:1px solid var(--border); background:var(--bg); }
td { padding:9px 10px; border-bottom:1px solid var(--border); }
tbody tr:hover { background:#fafbfc; }
.pill { font-size:10.5px; padding:2px 9px; border-radius:99px; font-weight:600; }
.pill.ok { background:#dcfce7; color:var(--ok); } .pill.run { background:#dbeafe; color:#1d4ed8; }
.pill.fail { background:#fee2e2; color:var(--err); } .pill.mut { background:#ede9fe; color:var(--purple); }
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
.bar-row .name { width:110px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bar-row .track { flex:1; height:12px; background:#eef1f5; border-radius:6px; overflow:hidden; }
.bar-row .fill { height:100%; background:var(--accent); border-radius:6px; }
.bar-row .val { width:32px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
.search { width:100%; border:1px solid var(--border); border-radius:7px; padding:8px 12px; font-size:13px; margin-bottom:10px; background:#fff; }
.search:focus { outline:none; border-color:var(--accent); }
.view { display:none; } .view.active { display:block; }
@media (max-width:900px){ .cards{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} }
</style>
</head>
<body>
<div class="head">
<div class="brand"><span class="sq">am</span> agent-manager</div>
<div class="tabs">
<div class="tab active" data-view="overview">Vue d'ensemble</div>
<div class="tab" data-view="stats">Statistiques</div>
<div class="tab" data-view="sessions">Sessions</div>
<div class="tab" data-view="events">Événements</div>
<div class="tab" data-view="projects">Projets</div>
</div>
<div class="spacer"></div>
<span class="hbadge ok">● local</span>
<span class="hbadge">127.0.0.1:7878 · v0.6.0</span>
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
</div>
<div class="wrap">
<div class="page-title" id="view-title">Vue d'ensemble</div>
<div class="page-sub" id="last-update">Dernière mise à jour : il y a 5 s · lang FR</div>
<section class="view active" id="overview">
<div class="cards">
<div class="card"><div class="l">Agents installés</div><div class="v">9</div><div class="d up">▲ +1 cette semaine</div></div>
<div class="card"><div class="l">En cours</div><div class="v">2</div><div class="d up">● claude-code · codex</div></div>
<div class="card"><div class="l">Sessions (7 j)</div><div class="v">47</div><div class="d up">▲ +12 %</div></div>
<div class="card"><div class="l">Événements (24 h)</div><div class="v">132</div><div class="d down">▼ 8 % vs hier</div></div>
</div>
<div class="grid2">
<div class="panel">
<h3>Top agents — sessions (7 j)</h3>
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
</div>
<div class="panel">
<h3>Activité — sessions par jour (14 j)</h3>
<svg viewBox="0 0 480 150" width="100%" height="150">
<defs><linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2563eb" stop-opacity=".2"/><stop offset="1" stop-color="#2563eb" stop-opacity="0"/></linearGradient></defs>
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g2)"/>
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10.5px;font-family:ui-monospace,monospace;margin-top:4px"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
</div>
</div>
<div class="panel">
<h3>Sessions — 7 derniers jours</h3>
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
<tbody id="sess-body-ov">
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="stats">
<div class="panel">
<h3>Statistiques par agent — période 30 j</h3>
<table>
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="sessions">
<div class="panel">
<h3>Sessions — 7 derniers jours</h3>
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
<tbody id="sess-body">
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="events">
<div class="panel">
<h3>Journal d'événements — aujourd'hui</h3>
<table>
<thead><tr><th>Heure</th><th>Type</th><th>Agent</th><th>Détail</th></tr></thead>
<tbody>
<tr><td class="mono">17:18</td><td><span class="pill run">start</span></td><td>codex</td><td>projet flowdeck</td></tr>
<tr><td class="mono">16:40</td><td><span class="pill ok">stop</span></td><td>claude-code</td><td>session 1 h 05 min</td></tr>
<tr><td class="mono">15:34</td><td><span class="pill run">start</span></td><td>claude-code</td><td>projet agent-manager</td></tr>
<tr><td class="mono">14:33</td><td><span class="pill fail">run</span></td><td>aider</td><td>exit code 1</td></tr>
<tr><td class="mono">13:05</td><td><span class="pill mut">update</span></td><td>jcode</td><td>0.76.0 → 0.78.1</td></tr>
<tr><td class="mono">10:02</td><td><span class="pill mut">install</span></td><td>opencode</td><td>0.14.2 — méthode binary</td></tr>
</tbody>
</table>
</div>
</section>
<section class="view" id="projects">
<div class="panel">
<h3>Projets</h3>
<table>
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
<tbody>
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<script>
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
document.querySelectorAll('.tab').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
document.getElementById(el.dataset.view).classList.add('active');
document.getElementById('view-title').textContent = titles[el.dataset.view];
});
});
function fakeRefresh(){
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + new Date().toLocaleTimeString('fr-FR') + ' · lang FR';
}
function filterSessions(q){
q = q.toLowerCase();
document.querySelectorAll('#sess-body tr, #sess-body-ov tr').forEach(tr => {
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
</script>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am web — Terminal dense (mockup)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0d1117; --panel:#10161f; --border:#212a36; --fg:#c9d4e0; --muted:#6e7b8a;
--accent:#3fb950; --accent2:#58a6ff; --ok:#3fb950; --warn:#d29922; --err:#f85149; --purple:#bc8cff;
}
body {
font-family:ui-monospace,SFMono-Regular,Consolas,"Cascadia Mono",Menlo,monospace;
background:var(--bg); color:var(--fg); font-size:12.5px; line-height:1.5;
}
.top {
border-bottom:1px solid var(--border); padding:9px 16px; display:flex; align-items:center; gap:16px;
background:var(--panel); position:sticky; top:0; z-index:5;
}
.top .prompt { color:var(--accent); font-weight:700; }
.top .brand { color:var(--fg); }
.tabs { display:flex; gap:2px; }
.tab { padding:3px 10px; cursor:pointer; color:var(--muted); border:1px solid transparent; border-radius:5px; }
.tab:hover { color:var(--fg); }
.tab.active { color:var(--accent); background:rgba(63,185,80,.08); border-color:rgba(63,185,80,.3); }
.top .spacer { flex:1; }
.stat { color:var(--muted); font-size:11.5px; }
.stat b { color:var(--ok); font-weight:400; }
.btn { background:transparent; border:1px solid var(--border); color:var(--fg); border-radius:5px; padding:3px 10px; font-family:inherit; font-size:12px; cursor:pointer; }
.btn:hover { border-color:var(--accent); color:var(--accent); }
.wrap { max-width:1180px; margin:0 auto; padding:14px 16px 40px; }
/* overview row */
.kv { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:14px; }
.kv-item { border:1px solid var(--border); background:var(--panel); border-radius:6px; padding:9px 12px; }
.kv-item .k { color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; }
.kv-item .v { font-size:21px; font-weight:700; color:var(--fg); margin-top:2px; }
.kv-item .v small { color:var(--muted); font-size:11px; font-weight:400; }
.kv-item.running .v { color:var(--accent); }
.kv-item.running .v::before { content:"● "; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:14px; }
.box { border:1px solid var(--border); background:var(--panel); border-radius:6px; overflow:hidden; }
.box .h { padding:7px 12px; border-bottom:1px solid var(--border); color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; display:flex; justify-content:space-between; }
.box .h .n { color:var(--accent); text-transform:none; letter-spacing:0; }
.box .b { padding:10px 12px; }
/* bars */
.br { display:grid; grid-template-columns:95px 1fr 30px; gap:8px; align-items:center; margin-bottom:6px; }
.br .nm { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.br .tr { height:10px; background:var(--bg); border-radius:3px; overflow:hidden; }
.br .fl { height:100%; background:var(--accent); opacity:.85; }
.br .vl { text-align:right; color:var(--muted); }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; color:var(--muted); font-weight:400; font-size:10.5px; text-transform:uppercase; letter-spacing:.6px; padding:5px 10px; border-bottom:1px solid var(--border); }
td { padding:5px 10px; border-bottom:1px solid rgba(33,42,54,.6); white-space:nowrap; }
td.wrap { white-space:normal; }
tbody tr:hover { background:rgba(63,185,80,.05); }
.st { font-size:11px; } .st::before { content:"● "; }
.st.ok { color:var(--ok); } .st.run { color:var(--accent2); } .st.fail { color:var(--err); } .st.mut { color:var(--purple); }
.dim { color:var(--muted); }
.view { display:none; } .view.active { display:block; }
@media (max-width:900px){ .row{grid-template-columns:1fr;} .kv{grid-template-columns:repeat(2,1fr);} }
</style>
</head>
<body>
<div class="top">
<span class="prompt">$</span><span class="brand">am web</span>
<div class="tabs">
<div class="tab active" data-view="overview">overview</div>
<div class="tab" data-view="stats">stats</div>
<div class="tab" data-view="sessions">sessions</div>
<div class="tab" data-view="events">events</div>
<div class="tab" data-view="projects">projects</div>
</div>
<div class="spacer"></div>
<span class="stat">v0.6.0 · <b>●</b> local · 127.0.0.1:7878</span>
<button class="btn" onclick="fakeRefresh()">↻ refresh</button>
</div>
<div class="wrap">
<section class="view active" id="overview">
<div class="kv">
<div class="kv-item"><div class="k">agents installés</div><div class="v">9</div></div>
<div class="kv-item running"><div class="k">en cours</div><div class="v">2</div></div>
<div class="kv-item"><div class="k">sessions 7j</div><div class="v">47 <small>▲12%</small></div></div>
<div class="kv-item"><div class="k">events 24h</div><div class="v">132 <small>▼8%</small></div></div>
</div>
<div class="row">
<div class="box">
<div class="h">top agents · sessions 7j</div>
<div class="b">
<div class="br"><span class="nm">claude-code</span><div class="tr"><div class="fl" style="width:100%"></div></div><span class="vl">18</span></div>
<div class="br"><span class="nm">aider</span><div class="tr"><div class="fl" style="width:50%"></div></div><span class="vl">9</span></div>
<div class="br"><span class="nm">codex</span><div class="tr"><div class="fl" style="width:44%"></div></div><span class="vl">8</span></div>
<div class="br"><span class="nm">gemini-cli</span><div class="tr"><div class="fl" style="width:28%"></div></div><span class="vl">5</span></div>
<div class="br"><span class="nm">jcode</span><div class="tr"><div class="fl" style="width:22%"></div></div><span class="vl">4</span></div>
<div class="br"><span class="nm">opencode</span><div class="tr"><div class="fl" style="width:17%"></div></div><span class="vl">3</span></div>
</div>
</div>
<div class="box">
<div class="h">activité 14j <span class="n">sessions/jour</span></div>
<div class="b">
<svg viewBox="0 0 480 150" width="100%" height="140">
<defs><linearGradient id="g3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3fb950" stop-opacity=".3"/><stop offset="1" stop-color="#3fb950" stop-opacity="0"/></linearGradient></defs>
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g3)"/>
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3fb950" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10px"><span>04/08</span><span>11/08</span><span>18/08</span></div>
</div>
</div>
</div>
<div class="box">
<div class="h">sessions en cours</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>durée</th><th>depuis</th></tr></thead>
<tbody>
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td>3h12m</td><td class="dim">14:26</td></tr>
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td>14m</td><td class="dim">17:18</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="stats">
<div class="box">
<div class="h">stats par agent · 30j</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>agent</th><th>sessions</th><th>durée</th><th>succès</th><th>coût</th><th>dernière act.</th></tr></thead>
<tbody>
<tr><td>claude-code</td><td>54</td><td>38h12m</td><td><span class="st ok">94%</span></td><td class="dim">12,40 €</td><td class="dim">2 min</td></tr>
<tr><td>aider</td><td>31</td><td>21h48m</td><td><span class="st ok">87%</span></td><td class="dim">4,10 €</td><td class="dim">3 h</td></tr>
<tr><td>codex</td><td>27</td><td>19h05m</td><td><span class="st ok">89%</span></td><td class="dim">8,75 €</td><td class="dim">14 min</td></tr>
<tr><td>gemini-cli</td><td>14</td><td>6h33m</td><td><span class="st ok">79%</span></td><td class="dim">0 € local</td><td class="dim">5 h</td></tr>
<tr><td>jcode</td><td>12</td><td>5h02m</td><td><span class="st run">67%</span></td><td class="dim">1,20 €</td><td class="dim">hier</td></tr>
<tr><td>opencode</td><td>9</td><td>4h40m</td><td><span class="st fail">55%</span></td><td class="dim">0,90 €</td><td class="dim">2 j</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="sessions">
<div class="box">
<div class="h">sessions · 7j</div>
<div class="b" style="padding:8px 12px 0">
<input id="sess-search" placeholder="$ grep sessions…" style="width:100%;background:var(--bg);border:1px solid var(--border);color:var(--fg);font-family:inherit;font-size:12px;padding:6px 10px;border-radius:4px;margin-bottom:6px" oninput="filterSessions(this.value)">
</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>statut</th><th>durée</th><th>fin</th></tr></thead>
<tbody id="sess-body">
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="st run">running</span></td><td>3h12m</td><td class="dim">—</td></tr>
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="st run">running</span></td><td>14m</td><td class="dim">—</td></tr>
<tr><td class="dim">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>1h05m</td><td class="dim">16:40</td></tr>
<tr><td class="dim">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="st fail">failed</span></td><td>12m</td><td class="dim">14:33</td></tr>
<tr><td class="dim">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="st ok">done</span></td><td>42m</td><td class="dim">12:12</td></tr>
<tr><td class="dim">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>2h47m</td><td class="dim">23:50</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="events">
<div class="box">
<div class="h">events · aujourd'hui</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>heure</th><th>type</th><th>agent</th><th class="wrap">détail</th></tr></thead>
<tbody>
<tr><td class="dim">17:18</td><td><span class="st run">start</span></td><td>codex</td><td class="wrap">projet flowdeck</td></tr>
<tr><td class="dim">16:40</td><td><span class="st ok">stop</span></td><td>claude-code</td><td class="wrap">session 1h05m</td></tr>
<tr><td class="dim">15:34</td><td><span class="st run">start</span></td><td>claude-code</td><td class="wrap">projet agent-manager</td></tr>
<tr><td class="dim">14:33</td><td><span class="st fail">run</span></td><td>aider</td><td class="wrap">exit code 1 — fichier manquant</td></tr>
<tr><td class="dim">13:05</td><td><span class="st mut">update</span></td><td>jcode</td><td class="wrap">0.76.0 → 0.78.1</td></tr>
<tr><td class="dim">10:02</td><td><span class="st mut">install</span></td><td>opencode</td><td class="wrap">0.14.2 — méthode binary</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="view" id="projects">
<div class="box">
<div class="h">projects</div>
<div class="b" style="padding:0">
<table>
<thead><tr><th>projet</th><th>agents</th><th>sessions 7j</th><th>temps</th><th>dernière act.</th></tr></thead>
<tbody>
<tr><td>agent-manager <span class="dim">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="dim">41h</td><td class="dim">2 min</td></tr>
<tr><td>flowdeck <span class="dim">· Python</span></td><td>codex · aider</td><td>11</td><td class="dim">19h</td><td class="dim">14 min</td></tr>
<tr><td>obsigate <span class="dim">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="dim">22h</td><td class="dim">3 h</td></tr>
<tr><td>imago <span class="dim">· Python</span></td><td>gemini-cli</td><td>4</td><td class="dim">14h</td><td class="dim">5 h</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<script>
document.querySelectorAll('.tab').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
document.getElementById(el.dataset.view).classList.add('active');
});
});
function fakeRefresh(){}
function filterSessions(q){
q = q.toLowerCase();
document.querySelectorAll('#sess-body tr').forEach(tr => {
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
</script>
</body>
</html>
+105
View File
@@ -111,6 +111,46 @@ pub fn apply_env_map_resolved(
} }
} }
/// Substitute the {model}, {provider} and {base_url} placeholders of a
/// setup hint with the resolved provider configuration (v1.1.4). When no
/// provider is resolved, the hint is returned unchanged.
pub fn render_hint(
hint: &str,
resolved: &Option<(String, Option<String>)>,
config: &Config,
) -> String {
let Some((pname, model)) = resolved else {
return hint.to_string();
};
let base_url = crate::providers::get(config, pname)
.map(|d| d.base_url.clone())
.unwrap_or_default();
hint.replace("{provider}", pname)
.replace(
"{model}",
model.as_deref().unwrap_or("{model}"),
)
.replace("{base_url}", &base_url)
}
/// Print the manual setup commands of an agent (agents that manage their
/// own configuration, e.g. picoclaw onboard/model/auth). No-op when the
/// agent declares none (v1.1.4).
fn print_hints(
app: &App,
agent: &AgentDef,
resolved: &Option<(String, Option<String>)>,
) {
if agent.setup_hints.is_empty() {
return;
}
app.log
.success(crate::i18n::tr("Configuration manuelle requise — à exécuter :"));
for hint in &agent.setup_hints {
app.log.info(&format!(" {}", render_hint(hint, resolved, &app.config)));
}
}
/// Write the agent configuration files after a successful install /// Write the agent configuration files after a successful install
/// (issue #91). The caller skips this on `--no-config`, on non-configurable /// (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: /// agents, and in dry-run (never reached). Never writes a token in clear:
@@ -124,6 +164,7 @@ pub fn apply_post_install(
app.log.info(crate::i18n::tr( app.log.info(crate::i18n::tr(
"agent sans adaptateur de config — voir sa doc", "agent sans adaptateur de config — voir sa doc",
)); ));
print_hints(app, agent, resolved);
return Ok(()); return Ok(());
}; };
if cfg.files.is_empty() { if cfg.files.is_empty() {
@@ -136,12 +177,14 @@ pub fn apply_post_install(
"config par variables d'env — injectée au démarrage", "config par variables d'env — injectée au démarrage",
)); ));
} }
print_hints(app, agent, resolved);
return Ok(()); return Ok(());
} }
let Some((pname, model)) = resolved else { let Some((pname, model)) = resolved else {
app.log.warn(crate::i18n::tr( app.log.warn(crate::i18n::tr(
"aucun provider résolu — fichiers de config non écrits", "aucun provider résolu — fichiers de config non écrits",
)); ));
print_hints(app, agent, resolved);
return Ok(()); return Ok(());
}; };
let Some(def) = crate::providers::get(&app.config, pname) else { let Some(def) = crate::providers::get(&app.config, pname) else {
@@ -408,6 +451,7 @@ mod tests {
install: None, install: None,
dependencies: vec![], dependencies: vec![],
run: Some("x".to_string()), run: Some("x".to_string()),
detect: None,
args: vec![], args: vec![],
env: BTreeMap::new(), env: BTreeMap::new(),
version: None, version: None,
@@ -417,6 +461,7 @@ mod tests {
provider, provider,
model: None, model: None,
config: Some(config), config: Some(config),
setup_hints: vec![],
sandbox: None, sandbox: None,
tags: vec![], tags: vec![],
installable: false, installable: false,
@@ -514,4 +559,64 @@ mod tests {
); );
assert_eq!(provider_pref(&agent2), Some("openai")); assert_eq!(provider_pref(&agent2), Some("openai"));
} }
#[test]
fn render_hint_substitutes_resolved_values() {
let c: Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n default_provider: openrouter\n providers:\n openrouter:\n base_url: https://openrouter.ai/api/v1\n default_model: deepseek/deepseek-chat-v3-0324\n models: [deepseek/deepseek-chat-v3-0324]\n",
)
.unwrap();
let resolved = Some(("openrouter".to_string(), Some("deepseek/deepseek-chat-v3-0324".to_string())));
assert_eq!(
render_hint("picoclaw model {model}", &resolved, &c),
"picoclaw model deepseek/deepseek-chat-v3-0324"
);
assert_eq!(render_hint("picoclaw onboard", &resolved, &c), "picoclaw onboard");
assert_eq!(
render_hint("cli --provider {provider} --url {base_url}", &resolved, &c),
"cli --provider openrouter --url https://openrouter.ai/api/v1"
);
// No resolved provider: the hint is left untouched.
assert_eq!(
render_hint("picoclaw model {model}", &None, &c),
"picoclaw model {model}"
);
// Resolved provider without a model: {model} stays visible.
let no_model = Some(("openrouter".to_string(), None));
assert_eq!(
render_hint("picoclaw model {model}", &no_model, &c),
"picoclaw model {model}"
);
}
#[test]
fn apply_post_install_with_setup_hints_is_ok() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app = crate::app::App::from_cli(cli).unwrap();
let mut agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: None,
},
None,
);
agent.config = None;
agent.setup_hints = vec![
"picoclaw onboard".to_string(),
"picoclaw model {model}".to_string(),
];
// No provider resolved: hints printed with placeholders, no crash.
assert!(apply_post_install(&app, &agent, &None).is_ok());
assert!(apply_post_install(&app, &agent, &Some(("openrouter".to_string(), None))).is_ok());
}
} }
+42
View File
@@ -214,6 +214,15 @@ pub enum Command {
#[arg(long)] #[arg(long)]
yes: bool, 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 an agent (foreground by default, or detached with --background)
Start(StartArgs), Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout) /// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -700,6 +709,39 @@ pub struct WebArgs {
pub no_open: bool, 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). /// Arguments of am serve (issue #80).
#[derive(Args, Debug, Clone, Default)] #[derive(Args, Debug, Clone, Default)]
pub struct ServeArgs { pub struct ServeArgs {
+354
View File
@@ -0,0 +1,354 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
//! context files (default: the current directory). Exec mode generates the
//! shell command through aichat (`--code` — never executed by aichat),
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
//! default) and only executes after confirmation when required.
use crate::app::App;
use crate::cli::AiArgs;
use crate::commands::{require_agent, resolve_exec};
use crate::events::{Event, EventKind};
use crate::shell_ai::{Decision, SafetyMode};
use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
// Onboarding hint (v1.1.0 F1) — non-blocking: the AI commands propose
// `am setup` when no provider is configured, then continue anyway.
if crate::setup::needs_setup(app) {
app.log.info(crate::i18n::tr_in(
app.lang(),
"am n'est pas configuré — lancez am setup (provider + token)",
));
}
// F2 (v1.1.0): aichat must be INSTALLED (not only in the catalog) to
// power am ai. Offer the installation when missing.
if !aichat_installed(app) {
let ask_install = !app.cli.yes
&& app.confirm(crate::i18n::tr_in(
app.lang(),
"aichat est manquant. Installer ?",
))?;
if app.cli.yes || ask_install {
app.log.info(crate::i18n::tr_in(app.lang(), "Installation d'aichat…"));
crate::commands::install_cmd::run(app, "aichat", None, false, None, None, false)?;
} else {
bail!(crate::i18n::tr_in(
app.lang(),
"installez aichat: am install aichat — puis réessayez"
));
}
}
let prompt = args.prompt.join(" ");
// Context files: explicit --files wins, else the current directory
// (issue #96: `aichat -f . "<prompt>"`). The default directory is
// walked by am to skip noise/vendored/binary files — aichat itself
// filters nothing and fails on non-UTF-8 content (e.g. .git/index).
let files: Vec<String> = if args.files.is_empty() {
context_files(std::path::Path::new("."))
} else {
args.files
.iter()
.map(|p| p.display().to_string())
.collect()
};
// REPL per-line flags combine with the CLI globals.
let yes = app.cli.yes || args.yes;
let dry_run = app.cli.dry_run || args.dry_run;
let agent = require_agent(app, "aichat")?;
if !args.exec {
return chat_mode(app, agent, &prompt, &files, args, dry_run);
}
exec_mode(app, &prompt, &files, args, yes, dry_run)
}
/// True when the aichat binary is available (installed or on PATH).
/// Uses a direct PATH lookup — the probe cache would mask a shim added
/// mid-test and is meant for agent inventories, not runtime checks.
fn aichat_installed(app: &App) -> bool {
app.state.get("aichat").ok().flatten().is_some()
|| which::which("aichat").is_ok()
}
/// Walk `dir` and collect text files usable as aichat context (the
/// default `--files` when the flag is absent). aichat filters nothing
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
/// - skipped directories: `.git`, vendored/build noise (`target`,
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
/// Then the survivors are sorted and taken while a total budget of
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
/// at 40 files. Empty when nothing qualifies (aichat is called without
/// `-f`).
fn context_files(dir: &std::path::Path) -> Vec<String> {
const MAX_FILES: usize = 40;
const MAX_SIZE: u64 = 64 * 1024;
const TOTAL_BUDGET: u64 = 128 * 1024;
const SKIP_DIRS: &[&str] = &[
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
".cargo", "obj", "out", ".svn", ".hg",
];
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
walk(&path, out);
} else {
if name.starts_with(".env") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if meta.len() > MAX_SIZE {
continue;
}
// UTF-8 sniff on the first bytes — aichat rejects binaries.
let Ok(mut f) = std::fs::File::open(&path) else { continue };
use std::io::Read;
let mut head = Vec::new();
if f.take(8192).read_to_end(&mut head).is_err()
|| std::str::from_utf8(&head).is_err()
{
continue;
}
out.push((path.display().to_string(), meta.len()));
}
}
}
let mut found = Vec::new();
walk(dir, &mut found);
found.sort();
let mut files = Vec::new();
let mut total: u64 = 0;
for (p, len) in found {
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
break;
}
total += len;
files.push(p);
}
files
}
/// Build the aichat `--agent` arguments for the requested role. Without a
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
/// definition is missing the roles are regenerated on the fly, and a
/// generation failure degrades gracefully (no role). An explicit unknown
/// role is an error.
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
let role = role.unwrap_or("am-copilot");
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
let present = || def.exists() || legacy.exists();
if present() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
if role == "am-copilot" {
if let Err(e) = crate::roles::generate_all(app) {
app.log.verbose(&format!("roles generation skipped: {e:#}"));
}
if present() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
app.log.warn(crate::i18n::tr_in(
app.lang(),
"rôle 'am-copilot' introuvable — lancez am setup --roles",
));
return Ok(Vec::new());
}
bail!(crate::i18n::tr_in(
app.lang(),
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
)
.replace("{}", role));
}
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
/// the provider/model environment applied when requested.
fn chat_mode(
app: &App,
agent: &crate::config::AgentDef,
prompt: &str,
files: &[String],
args: &AiArgs,
dry_run: bool,
) -> Result<i32> {
let mut extra: Vec<String> = Vec::new();
extra.extend(role_args(app, args.role.as_deref())?);
for f in files {
extra.push("-f".to_string());
extra.push(f.clone());
}
extra.push(prompt.to_string());
let (p_args, p_env) =
crate::shell_ai::provider_env(app, args.provider.as_deref(), args.model.as_deref())?;
extra.extend(p_args);
if dry_run {
app.log.dry(&format!(
"would run aichat {} (conversationnel)",
extra.join(" ")
));
return Ok(0);
}
let exec = resolve_exec(app, agent, &extra, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {}",
exec.program,
exec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec!["mode=chat".to_string(), "executed=true".to_string()]),
);
Ok(status.code().unwrap_or(1))
}
/// Exec mode: generate the command (aichat --dry-run), classify it, apply
/// the safety policy, then execute through the shell when allowed.
fn exec_mode(
app: &App,
prompt: &str,
files: &[String],
args: &AiArgs,
yes: bool,
dry_run: bool,
) -> Result<i32> {
if dry_run {
app.log.dry(&format!(
"would generate & classify via aichat (exec) — commande non exécutée"
));
}
// Exec mode asks for the raw command: default to the am-do role
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
let cmd = crate::shell_ai::generate(
app,
prompt,
files,
args.provider.as_deref(),
args.model.as_deref(),
&role,
)?;
let settings = app
.config
.settings
.shell_ai
.clone()
.unwrap_or_default();
let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns);
let certainty = crate::shell_ai::estimate_certainty(&cmd, risk);
let mode = settings.safety_mode();
// Show the generated command and its classification (issue #97 UX).
println!("🔒 Commande générée : {}", cmd);
println!(" Risk : {}", risk.as_str());
println!(" Certainty: {}%", certainty);
println!(
" Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)",
mode.as_str()
);
let decision = crate::shell_ai::decide(mode, risk, dry_run, yes);
let lang = app.lang();
let mut executed = false;
match decision {
Decision::Abort => {
let msg = if dry_run {
crate::i18n::tr_in(lang, "dry-run : commande non exécutée")
} else {
crate::i18n::tr_in(
lang,
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes",
)
};
app.log.info(msg);
}
Decision::Ask => {
let ok = app.confirm(crate::i18n::tr_in(lang, "Exécuter ?"))?;
if ok {
executed = true;
} else {
app.log.info(crate::i18n::tr_in(lang, "annulé"));
}
}
Decision::Execute => {
executed = true;
}
}
let code = if executed {
app.log.info(&format!("exécution: {}", cmd));
let code = crate::shell_ai::execute(app, &cmd)?;
code
} else {
0
};
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec![
"mode=exec".to_string(),
format!("risk={}", risk.as_str()),
format!("certainty={certainty}"),
format!("executed={executed}"),
]),
);
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_dry_run_is_the_default_policy() {
// The embedded config does not set shell_ai -> dry-run default.
let s = crate::config::ShellAiSettings::default();
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
#[test]
fn context_files_skips_binary_noise_and_secrets() {
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".git")).unwrap();
std::fs::create_dir_all(base.join("target")).unwrap();
std::fs::create_dir_all(base.join("src")).unwrap();
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
let files = context_files(&base);
let names: Vec<String> = files
.iter()
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
let _ = std::fs::remove_dir_all(&base);
}
}
+18 -6
View File
@@ -266,13 +266,22 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
for (bf, data) in manifest.files.iter().zip(blobs.iter()) { for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
let rel = Path::new(&bf.path); let rel = Path::new(&bf.path);
let target = if bf.path == "config.yaml" { let target = if bf.path == "config.yaml" {
// Config goes to the user config directory of machine B. // Config goes to the ACTIVE user config directory of this app
let dir = crate::config::user_config_dir() // (respects --config / isolated tests) — NOT the machine-global
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."))); // user config dir: importing must not clobber the real config.
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
dir.join(crate::config::CONFIG_FILE_NAME) dir.join(crate::config::CONFIG_FILE_NAME)
} else { } else {
state_dir.join(rel) let t = state_dir.join(rel);
if !t.starts_with(&state_dir) {
anyhow::bail!("security error: bundle contains unsafe path '{}'", bf.path);
}
t
}; };
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
@@ -292,8 +301,11 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
// confirmed; otherwise it is written next to it as config.migrated.yaml. // confirmed; otherwise it is written next to it as config.migrated.yaml.
let cfg_rel = "config.yaml"; let cfg_rel = "config.yaml";
if restored.iter().any(|r| r == cfg_rel) && !confirm { if restored.iter().any(|r| r == cfg_rel) && !confirm {
let dir = crate::config::user_config_dir() let dir = app
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."))); .paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
let imported = dir.join(crate::config::CONFIG_FILE_NAME); let imported = dir.join(crate::config::CONFIG_FILE_NAME);
if imported.exists() { if imported.exists() {
let target = dir.join("config.migrated.yaml"); let target = dir.join("config.migrated.yaml");
+59
View File
@@ -1,6 +1,7 @@
//! Command implementations and dispatch. //! Command implementations and dispatch.
pub mod agents_cmd; pub mod agents_cmd;
pub mod ai_cmd;
pub mod alias_cmd; pub mod alias_cmd;
pub mod annotations_cmd; pub mod annotations_cmd;
pub mod audit_cmd; pub mod audit_cmd;
@@ -106,6 +107,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()), Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()),
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")), Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes), 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::Start(a) => run_cmd::start(app, a),
Command::Stop { Command::Stop {
agent, agent,
@@ -310,6 +319,12 @@ pub fn resolve_exec(
Some(e) if !e.bins.is_empty() => { Some(e) if !e.bins.is_empty() => {
let p = std::path::PathBuf::from(&e.bins[0]); let p = std::path::PathBuf::from(&e.bins[0]);
if p.exists() || app.dry_run() { if p.exists() || app.dry_run() {
// The recorded bin replaces the run command's first token
// (e.g. "picoclaw"): consume it so it is not appended to
// the argument list. Regression: am start <agent géré>
// lançait "<bin> <token-run>" — picoclaw rejetait son propre
// nom comme sous-commande inconnue.
tokens.remove(0);
p.display().to_string() p.display().to_string()
} else { } else {
tokens.remove(0) tokens.remove(0)
@@ -464,6 +479,7 @@ pub fn select_method<'a>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use clap::Parser;
#[test] #[test]
fn routes_cmd_scripts_through_comspec() { fn routes_cmd_scripts_through_comspec() {
@@ -489,4 +505,47 @@ mod tests {
assert_eq!(program, "node"); assert_eq!(program, "node");
assert_eq!(args, vec!["-v".to_string()]); assert_eq!(args, vec!["-v".to_string()]);
} }
#[test]
fn resolve_exec_installed_agent_does_not_leak_run_token() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents:\n - name: x\n run: x\n installable: false\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
app.paths.state_file = dir.join("state.json");
app.state = crate::state::StateStore::new(app.paths.state_file.clone());
// A recorded installed bin that exists: the run token "x" must be
// consumed, not appended to the argument list.
let bin = dir.join("x.exe");
std::fs::write(&bin, b"x").unwrap();
app.state
.set(&crate::state::InstalledEntry {
name: "x".to_string(),
version: Some("1.0".to_string()),
method: "binary".to_string(),
install_dir: dir.display().to_string(),
bins: vec![bin.display().to_string()],
run: "x".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
})
.unwrap();
let agent = app.catalog.resolve("x").unwrap();
let exec = resolve_exec(&app, agent, &[], &BTreeMap::new()).unwrap();
assert_eq!(exec.program, bin.display().to_string());
assert!(
exec.args.is_empty(),
"run token leaked into args: {:?}",
exec.args
);
}
} }
+2
View File
@@ -655,6 +655,7 @@ mod tests {
install: None, install: None,
dependencies: vec![], dependencies: vec![],
run: Some("demo".to_string()), run: Some("demo".to_string()),
detect: None,
args: vec![], args: vec![],
env: BTreeMap::new(), env: BTreeMap::new(),
version: None, version: None,
@@ -668,6 +669,7 @@ mod tests {
files: vec![], files: vec![],
provider_default: None, provider_default: None,
}), }),
setup_hints: vec![],
sandbox: None, sandbox: None,
tags: vec![], tags: vec![],
installable: false, installable: false,
+2
View File
@@ -190,6 +190,7 @@ mod tests {
install: None, install: None,
dependencies: vec![], dependencies: vec![],
run: Some(name.to_string()), run: Some(name.to_string()),
detect: None,
args: vec![], args: vec![],
env: BTreeMap::new(), env: BTreeMap::new(),
version: None, version: None,
@@ -199,6 +200,7 @@ mod tests {
provider: None, provider: None,
model: None, model: None,
config: None, config: None,
setup_hints: vec![],
sandbox: None, sandbox: None,
tags: tags.iter().map(|s| s.to_string()).collect(), tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false, installable: false,
+47
View File
@@ -103,6 +103,34 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("--json", "sortie machine-readable")], options: &[("--json", "sortie machine-readable")],
example: "suggest un agent pour du Python", 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 { TipEntry {
usage: "models", usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)", 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")], options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml", 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 { TipEntry {
usage: "audit", usage: "audit",
about: "qui a modifié quoi, quand (checksums config + journal d'événements)", about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
@@ -251,6 +288,16 @@ pub static SECTIONS: &[TipSection] = &[
], ],
example: "web --port 9090", 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 { TipEntry {
usage: "sync", usage: "sync",
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
+79
View File
@@ -162,6 +162,10 @@ pub struct Settings {
/// (issue #89). /// (issue #89).
#[serde(default)] #[serde(default)]
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>, 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 /// Anonymous opt-in telemetry (issue #76): aggregated counters only — never
@@ -200,6 +204,34 @@ impl Default for AskSettings {
} }
} }
/// 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). /// Community registry settings (issue #77).
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)] #[serde(default)]
@@ -398,6 +430,13 @@ pub struct AgentDef {
/// Command used to start the agent (e.g. "claude", "python -m nanobot"). /// Command used to start the agent (e.g. "claude", "python -m nanobot").
#[serde(default)] #[serde(default)]
pub run: Option<String>, 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. /// Default arguments appended to the run command.
#[serde(default)] #[serde(default)]
pub args: Vec<String>, pub args: Vec<String>,
@@ -428,6 +467,12 @@ pub struct AgentDef {
/// Post-install provider configuration (issue #91). /// Post-install provider configuration (issue #91).
#[serde(default)] #[serde(default)]
pub config: Option<AgentConfig>, pub config: Option<AgentConfig>,
/// Manual setup commands shown after install when the agent manages its
/// own configuration (onboard/model/auth...). Placeholders {model},
/// {provider} and {base_url} are substituted with the resolved values
/// (v1.1.4).
#[serde(default)]
pub setup_hints: Vec<String>,
/// Sandbox profile (issue #79): allowed commands, working directories /// Sandbox profile (issue #79): allowed commands, working directories
/// and network policy. Disabled by default — the unsandboxed mode /// and network policy. Disabled by default — the unsandboxed mode
/// stays available and documented. /// stays available and documented.
@@ -516,6 +561,12 @@ impl AgentDef {
}) })
} }
/// Binary probed on the PATH for external detection: the explicit
/// `detect` name when set, otherwise the first token of `run`.
pub fn detect_token(&self) -> Option<String> {
self.detect.clone().or_else(|| self.first_token())
}
/// The run command split into tokens (empty when unset). /// The run command split into tokens (empty when unset).
pub fn run_tokens(&self) -> Vec<String> { pub fn run_tokens(&self) -> Vec<String> {
self.run self.run
@@ -956,6 +1007,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.default_provider.is_some() { if o.default_provider.is_some() {
s.default_provider = o.default_provider; 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 // Providers merge key by key (issue #88): an overlay adds or replaces
// one provider without wiping the others. An explicit `null` in the // one provider without wiping the others. An explicit `null` in the
// overlay DELETES the provider (the standard YAML overlay pattern) so a // overlay DELETES the provider (the standard YAML overlay pattern) so a
@@ -1167,6 +1221,31 @@ mod tests {
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg)); 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] #[test]
fn expands_paths() { fn expands_paths() {
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value"); std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
+4
View File
@@ -52,6 +52,9 @@ pub enum EventKind {
Provider, Provider,
/// Sandbox refusal journalized for audit (issue #79). /// Sandbox refusal journalized for audit (issue #79).
Sandbox, Sandbox,
/// am ai — Shell AI action: generated, classified and/or executed
/// (issues #96 #97).
ShellAi,
} }
impl EventKind { impl EventKind {
@@ -79,6 +82,7 @@ impl EventKind {
EventKind::Lab => "lab", EventKind::Lab => "lab",
EventKind::Provider => "provider", EventKind::Provider => "provider",
EventKind::Sandbox => "sandbox", EventKind::Sandbox => "sandbox",
EventKind::ShellAi => "shell_ai",
} }
} }
} }
+46
View File
@@ -857,6 +857,25 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" }, 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 { HelpSpec {
name: "ask", name: "ask",
category: "Commands", category: "Commands",
@@ -876,6 +895,33 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" }, 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 --code — jamais exécutée par aichat), 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 { HelpSpec {
name: "registry", name: "registry",
category: "Commands", category: "Commands",
+28
View File
@@ -163,6 +163,24 @@ pub const CATALOG: &[(&str, &str)] = &[
("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»"), ("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?"), ("exécuter ces commandes ?", "run these commands?"),
("annulé", "cancelled"), ("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)"),
("rôle 'am-copilot' introuvable — lancez am setup --roles", "role 'am-copilot' not found — run am setup --roles"),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"), ("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>"), ("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"), ("installation refusée — source non fiable", "installation refused — untrusted source"),
@@ -196,6 +214,7 @@ pub const CATALOG: &[(&str, &str)] = &[
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"), ("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"), ("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"), ("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
("Configuration manuelle requise — à exécuter :", "Manual setup required — run:"),
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"), ("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"), ("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"), ("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"),
@@ -430,6 +449,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. /// Replace `{}` placeholders (positional) with the Display of each argument.
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String { fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
let mut out = String::with_capacity(template.len() + 16); let mut out = String::with_capacity(template.len() + 16);
+3 -1
View File
@@ -98,7 +98,9 @@ pub fn sh(
env: &BTreeMap<String, String>, env: &BTreeMap<String, String>,
cwd: Option<&Path>, cwd: Option<&Path>,
) -> Result<RunOutput> { ) -> Result<RunOutput> {
let out = runner.run(cmd, args, env, cwd, true)?; // Installs are legitimately long (npm/cargo/go/... can take minutes):
// no probe timeout here, only run() (doctor/version probes) is bounded.
let out = runner.run_untimed(cmd, args, env, cwd, true)?;
if out.code != 0 { if out.code != 0 {
let mut detail = out.stderr.trim().to_string(); let mut detail = out.stderr.trim().to_string();
if detail.is_empty() { if detail.is_empty() {
+3
View File
@@ -14,6 +14,9 @@
pub mod app; pub mod app;
pub mod agent_config; pub mod agent_config;
pub mod ask; pub mod ask;
pub mod roles;
pub mod setup;
pub mod shell_ai;
pub mod automation; pub mod automation;
pub mod backup; pub mod backup;
pub mod catalog; pub mod catalog;
+56 -19
View File
@@ -30,7 +30,6 @@ impl ExternalInfo {
/// Detect the version of an external binary (--version), bounded by a /// Detect the version of an external binary (--version), bounded by a
/// timeout so a misbehaving agent can never hang the whole listing. /// timeout so a misbehaving agent can never hang the whole listing.
pub fn detect_external_version(bin_path: &Path) -> Option<String> { pub fn detect_external_version(bin_path: &Path) -> Option<String> {
use std::io::Read;
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string()); let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
let mut cmd = std::process::Command::new(&prog); let mut cmd = std::process::Command::new(&prog);
cmd.args(&prefix) cmd.args(&prefix)
@@ -41,6 +40,24 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
let Ok(mut child) = cmd.spawn() else { let Ok(mut child) = cmd.spawn() else {
return None; return None;
}; };
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_handle = std::thread::spawn(move || {
let mut out = String::new();
if let Some(mut pipe) = stdout_pipe {
let _ = std::io::Read::read_to_string(&mut pipe, &mut out);
}
out
});
let stderr_handle = std::thread::spawn(move || {
let mut err = String::new();
if let Some(mut pipe) = stderr_pipe {
let _ = std::io::Read::read_to_string(&mut pipe, &mut err);
}
err
});
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) { let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
Ok(Some(s)) => Some(s), Ok(Some(s)) => Some(s),
@@ -51,22 +68,14 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
} }
Err(_) => None, Err(_) => None,
}; };
let Some(status) = status else { let stdout = stdout_handle.join().unwrap_or_default();
return None; let stderr = stderr_handle.join().unwrap_or_default();
};
let status = status?;
if !status.success() { if !status.success() {
return None; return None;
} }
let mut stdout = String::new(); let combined = format!("{stdout}\n{stderr}");
let mut stderr = String::new();
if let Some(mut o) = child.stdout.take() {
let _ = o.read_to_string(&mut stdout);
}
if let Some(mut e) = child.stderr.take() {
let _ = e.read_to_string(&mut stderr);
}
let combined = format!("{stdout}
{stderr}");
crate::version::find_version(&combined) crate::version::find_version(&combined)
} }
@@ -79,6 +88,11 @@ struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from. /// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)] #[serde(default)]
path_hash: u64, 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). /// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)] #[serde(default)]
tokens: BTreeMap<String, Option<String>>, tokens: BTreeMap<String, Option<String>>,
@@ -98,6 +112,26 @@ fn path_hash() -> u64 {
h 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. /// 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. /// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> { fn build_path_index() -> BTreeMap<String, PathBuf> {
@@ -163,13 +197,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
.unwrap_or_default(); .unwrap_or_default();
let mut dirty = false; 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(); 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(); let index = build_path_index();
cache.tokens.clear(); cache.tokens.clear();
for agent in app.catalog.agents() { for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else { let Some(token) = agent.detect_token() else {
continue; continue;
}; };
if !cache.tokens.contains_key(&token) { if !cache.tokens.contains_key(&token) {
@@ -180,6 +216,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
} }
} }
cache.path_hash = current_hash; cache.path_hash = current_hash;
cache.token_hash = current_tokens;
dirty = true; dirty = true;
} }
@@ -195,7 +232,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
let index = build_path_index(); let index = build_path_index();
cache.tokens.clear(); cache.tokens.clear();
for agent in app.catalog.agents() { for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else { let Some(token) = agent.detect_token() else {
continue; continue;
}; };
if !cache.tokens.contains_key(&token) { if !cache.tokens.contains_key(&token) {
@@ -211,7 +248,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) { if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue; continue;
} }
let Some(token) = agent.first_token() else { let Some(token) = agent.detect_token() else {
continue; continue;
}; };
if let Some(Some(path)) = cache.tokens.get(&token) { if let Some(Some(path)) = cache.tokens.get(&token) {
+13 -8
View File
@@ -17,14 +17,19 @@ pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
pub fn is_running(pid: u32) -> bool { pub fn is_running(pid: u32) -> bool {
#[cfg(windows)] #[cfg(windows)]
{ {
let out = Command::new("tasklist") use windows_sys::Win32::Foundation::{CloseHandle, FALSE, STILL_ACTIVE};
.args(["/FI", &format!("PID eq {pid}"), "/NH"]) use windows_sys::Win32::System::Threading::{
.output(); GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
match out { };
Ok(o) => String::from_utf8_lossy(&o.stdout) unsafe {
.split_whitespace() let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
.any(|w| w == pid.to_string()), if handle == 0 {
Err(_) => false, return false;
}
let mut code: u32 = 0;
let ok = GetExitCodeProcess(handle, &mut code);
CloseHandle(handle);
ok != 0 && code == STILL_ACTIVE as u32
} }
} }
#[cfg(not(windows))] #[cfg(not(windows))]
+82 -3
View File
@@ -113,6 +113,9 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("registry", "publish, search and install agent catalogs (Gitea)"), ("registry", "publish, search and install agent catalogs (Gitea)"),
("providers", "manage the LLM provider registry (base URLs, models, default)"), ("providers", "manage the LLM provider registry (base URLs, models, default)"),
("suggest", "recommend an agent for a request"), ("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)"), ("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"), ("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"), ("schedule", "plan am commands (daily) and check the fleet health"),
@@ -238,7 +241,7 @@ impl AmCompleter {
"start", "stop", "restart", "run", "doctor", "config", "completion", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang", "self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "registry", "audit", "profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "setup", "registry", "audit",
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins", "service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
], ],
@@ -798,7 +801,7 @@ pub fn banner_box(
.to_string(), .to_string(),
)); ));
rows.push(inner( 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( rows.push(inner(
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(), " automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
@@ -1108,6 +1111,14 @@ pub fn run(app: &App) -> Result<i32> {
app.log.verbose(&format!("history migration skipped: {e:#}")); app.log.verbose(&format!("history migration skipped: {e:#}"));
} }
print!("{}", banner(app, &session, &sid)); 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) { let result = match run_with_editor(app, &mut session, &sid) {
Ok(code) => Ok(code), Ok(code) => Ok(code),
Err(e) => { Err(e) => {
@@ -1602,6 +1613,69 @@ fn handle_line(
yes: flag("--yes"), 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()) { "alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add { Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(), name: rest[1].clone(),
@@ -2014,13 +2088,18 @@ pub fn is_am_command(word: &str) -> bool {
| "tip" | "tip"
| "models" | "models"
| "catalog" | "catalog"
| "registry"
| "providers" | "providers"
| "suggest" | "suggest"
| "ask"
| "ai"
| "setup"
| "audit" | "audit"
| "service" | "service"
| "schedule" | "schedule"
| "monitor" | "monitor"
| "web" | "web"
| "serve"
| "sync" | "sync"
| "migrate" | "migrate"
| "playbook" | "playbook"
@@ -2676,7 +2755,7 @@ mod tests {
for cmd in [ for cmd in [
"list", "status", "search", "info", "install", "uninstall", "update", "start", "list", "status", "search", "info", "install", "uninstall", "update", "start",
"stop", "restart", "run", "doctor", "config", "completion", "self-update", "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}"); assert!(is_am_command(cmd), "{cmd}");
} }
+372
View File
@@ -0,0 +1,372 @@
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
//!
//! Generates aichat agent definitions 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.
//!
//! Two layouts are written for compatibility with every aichat version:
//! - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
//! + `functions/agents.txt` (registry), under the aichat config dir;
//! - aichat <= 0.29 : legacy markdown agents `agents/<name>.md`.
//!
//! 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",
];
/// Base config directory of aichat. Candidates are probed in order: an
/// existing aichat config wins, otherwise the platform default (XDG on
/// unix, %APPDATA% on Windows).
pub fn aichat_config_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"));
}
if let Some(home) = crate::config::home_dir() {
candidates.push(home.join(".config").join("aichat"));
}
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.push(PathBuf::from(appdata).join("aichat"));
}
}
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");
}
}
crate::config::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("aichat")
}
/// Legacy agents directory (aichat <= 0.29): `agents/<name>.md` files
/// with frontmatter. aichat >= 0.30 reads the new definitions instead
/// (see [`aichat_agent_def_dir`]); the .md files are still written so
/// older aichat versions keep working.
pub fn aichat_agents_dir() -> PathBuf {
aichat_config_dir().join("agents")
}
/// Functions directory (aichat >= 0.30): agent definitions live in
/// `functions/agents/<name>/index.yaml` and the agent registry in
/// `functions/agents.txt`.
pub fn aichat_functions_dir() -> PathBuf {
aichat_config_dir().join("functions")
}
/// Directory of one agent definition (aichat >= 0.30).
pub fn aichat_agent_def_dir(name: &str) -> PathBuf {
aichat_functions_dir().join("agents").join(name)
}
/// Path of the aichat configuration file (created only if absent).
pub fn aichat_config_path() -> PathBuf {
aichat_config_dir().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";
/// The (description, tools, body) of one role.
fn role_parts(name: &str) -> Option<(&'static str, &'static [&'static str], 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,
};
Some((desc, tools, body))
}
/// The aichat >= 0.30 agent definition (`index.yaml`): the subset of
/// aichat's AgentDefinition that am generates.
#[derive(serde::Serialize)]
struct AgentDefinition {
name: String,
description: String,
version: String,
instructions: String,
}
/// Build the legacy markdown content of one role (aichat <= 0.29).
pub fn role_content(name: &str) -> Option<String> {
let (desc, tools, body) = role_parts(name)?;
Some(format!(
"{}{}{}",
frontmatter(name, desc, tools),
body,
LANG_FOOTER
))
}
/// The aichat >= 0.30 `index.yaml` definition of one role.
pub fn role_definition(name: &str) -> Option<String> {
let (desc, _tools, body) = role_parts(name)?;
let def = AgentDefinition {
name: name.to_string(),
description: desc.to_string(),
version: "1.0.0".to_string(),
instructions: format!("{body}{LANG_FOOTER}"),
};
serde_yaml::to_string(&def).ok()
}
/// Write every am-* role into the aichat agent stores (creates the
/// directories when missing). Two layouts are written so any aichat
/// version works:
/// - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
/// + `functions/agents.txt` (registry);
/// - aichat <= 0.29 : legacy markdown `agents/<name>.md`.
/// Existing files with the same name are overwritten — they are generated
/// artifacts of am.
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
// New layout (aichat >= 0.30): index.yaml definitions + agents.txt.
let funcs_dir = aichat_functions_dir();
let agents_dir = funcs_dir.join("agents");
std::fs::create_dir_all(&agents_dir)?;
for name in ROLE_NAMES {
let def = role_definition(name).expect("known role");
let def_path = agents_dir.join(name).join("index.yaml");
std::fs::create_dir_all(def_path.parent().expect("parent dir"))?;
std::fs::write(&def_path, def)?;
written.push(def_path);
}
let list_path = funcs_dir.join("agents.txt");
let mut list = String::from("# generated by `am setup` — aichat >= 0.30 agent registry\n");
list.push_str(&format!("{}\n", ROLE_NAMES.join("\n")));
std::fs::write(&list_path, list)?;
written.push(list_path);
// Legacy layout (aichat <= 0.29): markdown agents in agents/.
let legacy_dir = aichat_agents_dir();
std::fs::create_dir_all(&legacy_dir)?;
for name in ROLE_NAMES {
let content = role_content(name).expect("known role");
let path = legacy_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 role_definition_is_valid_index_yaml() {
for name in ROLE_NAMES {
let def = role_definition(name).expect("role");
assert!(def.starts_with("name:"), "{name}:\n{def}");
assert!(def.contains("description:"), "{name}");
assert!(def.contains("instructions:"), "{name}");
assert!(def.contains("Tu es"), "{name}");
}
}
#[test]
fn agent_def_dir_targets_functions_layout() {
let s = aichat_agent_def_dir("am-copilot")
.to_string_lossy()
.to_string();
assert!(s.contains("functions"), "{s}");
assert!(s.ends_with("am-copilot"), "{s}");
// The registry lives next to the definitions.
let registry = aichat_functions_dir().join("agents.txt");
assert!(registry.to_string_lossy().ends_with("agents.txt"));
}
#[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:?}");
}
}
+118 -7
View File
@@ -52,6 +52,20 @@ pub trait Runner {
capture: bool, capture: bool,
) -> Result<RunOutput>; ) -> Result<RunOutput>;
/// Run without the probe timeout: used for legitimately long
/// operations (package installs, git clones, ...). The default
/// implementation delegates to run().
fn run_untimed(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput> {
self.run(cmd, args, env, cwd, capture)
}
/// Locate a binary on the PATH (like the which command). /// Locate a binary on the PATH (like the which command).
fn which(&self, bin: &str) -> Option<PathBuf>; fn which(&self, bin: &str) -> Option<PathBuf>;
} }
@@ -73,14 +87,17 @@ impl<'a> SystemRunner<'a> {
} }
} }
impl<'a> Runner for SystemRunner<'a> { impl<'a> SystemRunner<'a> {
fn run( /// Shared implementation. The timeout applies only to captured
/// probes; long-running installs pass None via run_untimed().
fn run_inner(
&self, &self,
cmd: &str, cmd: &str,
args: &[String], args: &[String],
env: &BTreeMap<String, String>, env: &BTreeMap<String, String>,
cwd: Option<&Path>, cwd: Option<&Path>,
capture: bool, capture: bool,
timeout: Option<std::time::Duration>,
) -> Result<RunOutput> { ) -> Result<RunOutput> {
let display = if args.is_empty() { let display = if args.is_empty() {
cmd.to_string() cmd.to_string()
@@ -102,12 +119,70 @@ impl<'a> Runner for SystemRunner<'a> {
c.current_dir(dir); c.current_dir(dir);
} }
if capture { if capture {
let out = c c.stdout(std::process::Stdio::piped());
.output() c.stderr(std::process::Stdio::piped());
let mut child = c
.spawn()
.with_context(|| format!("failed to execute: {display}"))?; .with_context(|| format!("failed to execute: {display}"))?;
let code = out.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stdout_pipe = child.stdout.take();
let stderr = String::from_utf8_lossy(&out.stderr).to_string(); let stderr_pipe = child.stderr.take();
let stdout_handle = std::thread::spawn(move || {
let mut out = Vec::new();
if let Some(mut pipe) = stdout_pipe {
let _ = std::io::Read::read_to_end(&mut pipe, &mut out);
}
out
});
let stderr_handle = std::thread::spawn(move || {
let mut err = Vec::new();
if let Some(mut pipe) = stderr_pipe {
let _ = std::io::Read::read_to_end(&mut pipe, &mut err);
}
err
});
let status = if let Some(timeout) = timeout {
use wait_timeout::ChildExt;
match child.wait_timeout(timeout) {
Ok(Some(s)) => s,
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
self.log
.warn(format!("command timed out after {timeout:?}: {display}"));
let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default())
.to_string();
let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default())
.to_string();
return Ok(RunOutput {
code: -1,
stdout,
stderr,
});
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return Ok(RunOutput {
code: -1,
stdout: String::new(),
stderr: String::new(),
});
}
}
} else {
child
.wait()
.with_context(|| format!("failed to wait: {display}"))?
};
let stdout_bytes = stdout_handle.join().unwrap_or_default();
let stderr_bytes = stderr_handle.join().unwrap_or_default();
let code = status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
self.log.result(code, &stderr); self.log.result(code, &stderr);
Ok(RunOutput { Ok(RunOutput {
code, code,
@@ -126,6 +201,42 @@ impl<'a> Runner for SystemRunner<'a> {
} }
} }
}
impl<'a> Runner for SystemRunner<'a> {
/// Probes are bounded by a 30 s timeout so a misbehaving process can
/// never hang doctor/version detection.
fn run(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput> {
self.run_inner(
cmd,
args,
env,
cwd,
capture,
Some(std::time::Duration::from_secs(30)),
)
}
/// Installs and other legitimately long operations run without the
/// probe timeout (npm/cargo/go installs can take minutes).
fn run_untimed(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput> {
self.run_inner(cmd, args, env, cwd, capture, None)
}
fn which(&self, bin: &str) -> Option<PathBuf> { fn which(&self, bin: &str) -> Option<PathBuf> {
which::which(bin).ok() which::which(bin).ok()
} }
+8 -9
View File
@@ -103,10 +103,9 @@ mod tests {
use super::*; use super::*;
use clap::Parser; use clap::Parser;
fn test_app(sandbox_yaml: &str) -> App { fn test_app(sandbox_yaml: &str) -> (tempfile::TempDir, App) {
let guard = tempfile::tempdir().unwrap(); let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf(); let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml"); let cfg = dir.join("config.yaml");
std::fs::write( std::fs::write(
&cfg, &cfg,
@@ -116,7 +115,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
crate::app::App::from_cli(cli).unwrap() (guard, crate::app::App::from_cli(cli).unwrap())
} }
fn agent(app: &App) -> &AgentDef { fn agent(app: &App) -> &AgentDef {
@@ -125,7 +124,7 @@ mod tests {
#[test] #[test]
fn no_profile_means_unsandboxed() { fn no_profile_means_unsandboxed() {
let app = test_app(""); let (_guard, app) = test_app("");
assert!(profile_of(agent(&app)).is_none()); assert!(profile_of(agent(&app)).is_none());
let mut env = BTreeMap::new(); let mut env = BTreeMap::new();
// No profile: everything allowed, nothing refused. // No profile: everything allowed, nothing refused.
@@ -135,7 +134,7 @@ mod tests {
#[test] #[test]
fn disallowed_command_is_refused_and_journalized() { fn disallowed_command_is_refused_and_journalized() {
let app = test_app( let (_guard, app) = test_app(
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n", "sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
); );
let mut env = BTreeMap::new(); let mut env = BTreeMap::new();
@@ -161,7 +160,7 @@ mod tests {
#[test] #[test]
fn allowed_command_and_cwd_pass() { fn allowed_command_and_cwd_pass() {
let cwd = std::env::current_dir().unwrap(); let cwd = std::env::current_dir().unwrap();
let app = test_app(&format!( let (_guard, app) = test_app(&format!(
"sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n", "sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n",
cwd.display().to_string().replace('\\', "\\\\") cwd.display().to_string().replace('\\', "\\\\")
)); ));
@@ -172,7 +171,7 @@ mod tests {
#[test] #[test]
fn cwd_outside_perimeter_is_blocked() { fn cwd_outside_perimeter_is_blocked() {
let app = test_app( let (_guard, app) = test_app(
"sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n", "sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n",
); );
let mut env = BTreeMap::new(); let mut env = BTreeMap::new();
@@ -182,7 +181,7 @@ mod tests {
#[test] #[test]
fn network_off_injects_blocking_proxy_env() { fn network_off_injects_blocking_proxy_env() {
let app = test_app( let (_guard, app) = test_app(
"sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n", "sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n",
); );
let mut env = BTreeMap::new(); let mut env = BTreeMap::new();
@@ -194,7 +193,7 @@ mod tests {
#[test] #[test]
fn no_sandbox_flag_bypasses_the_profile() { fn no_sandbox_flag_bypasses_the_profile() {
let app = test_app( let (_guard, app) = test_app(
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n", "sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
); );
let mut env = BTreeMap::new(); let mut env = BTreeMap::new();
+13 -3
View File
@@ -25,8 +25,9 @@ impl SecretStore for KeyringStore {
if !idx.iter().any(|k| k == key) { if !idx.iter().any(|k| k == key) {
idx.push(key.to_string()); idx.push(key.to_string());
} }
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?; let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
let _ = index.set_password(&idx.join(",")); let _ = index.set_password(&json_idx);
Ok(()) Ok(())
} }
@@ -51,8 +52,9 @@ impl SecretStore for KeyringStore {
let mut idx = self.list().unwrap_or_default(); let mut idx = self.list().unwrap_or_default();
if idx.iter().any(|k| k == key) { if idx.iter().any(|k| k == key) {
idx.retain(|k| k != key); idx.retain(|k| k != key);
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?; let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
let _ = index.set_password(&idx.join(",")); let _ = index.set_password(&json_idx);
} }
Ok(existed) Ok(existed)
} }
@@ -60,7 +62,15 @@ impl SecretStore for KeyringStore {
fn list(&self) -> Result<Vec<String>> { fn list(&self) -> Result<Vec<String>> {
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?; let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
match index.get_password() { match index.get_password() {
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()), Ok(v) => {
if let Ok(vec) = serde_json::from_str::<Vec<String>>(&v) {
Ok(vec)
} else if v.contains('\0') {
Ok(v.split('\0').filter(|s| !s.is_empty()).map(String::from).collect())
} else {
Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect())
}
}
Err(keyring::Error::NoEntry) => Ok(Vec::new()), Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(e) => Err(anyhow!("keyring: {e}")), Err(e) => Err(anyhow!("keyring: {e}")),
} }
+11 -2
View File
@@ -370,9 +370,18 @@ fn ws_accept(key: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
} }
/// Write a single server frame (unmasked): opcode + 7-bit length + payload. /// Write a single server frame (unmasked): opcode + RFC 6455 length + payload.
fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> { fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> {
w.write_all(&[0x80 | opcode, payload.len() as u8])?; let len = payload.len();
if len <= 125 {
w.write_all(&[0x80 | opcode, len as u8])?;
} else if len <= 65535 {
w.write_all(&[0x80 | opcode, 126])?;
w.write_all(&(len as u16).to_be_bytes())?;
} else {
w.write_all(&[0x80 | opcode, 127])?;
w.write_all(&(len as u64).to_be_bytes())?;
}
w.write_all(payload)?; w.write_all(payload)?;
w.flush()?; w.flush()?;
Ok(()) Ok(())
+440
View File
@@ -0,0 +1,440 @@
//! 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;
/// Providers offered by the wizard: every provider defined in the merged
/// registry (embedded + user config, sorted), plus the local Ollama runtime
/// and a custom endpoint. Built dynamically so new registry providers
/// (e.g. the QwenCloud token-plan / pay-as-you-go pair) show up without a
/// code change — CHOICES used to be a hardcoded list that silently drifted.
fn choices(app: &App) -> Vec<String> {
let mut v: Vec<String> = crate::providers::defined(&app.config)
.map(|(k, _)| k.clone())
.collect();
v.sort();
if !v.iter().any(|p| p == "ollama") {
v.push("ollama".to_string());
}
v.push("custom".to_string());
v
}
/// 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()
}
/// Read one trimmed line from stdin. `None` on EOF — callers must not spin
/// on empty input when nothing is connected (pipes, --yes in CI).
fn read_line() -> Result<Option<String>> {
let mut line = String::new();
if std::io::stdin().read_line(&mut line)? == 0 {
return Ok(None);
}
Ok(Some(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()?.unwrap_or_default().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);
let choices = choices(app);
println!("Provider disponible :");
for (i, p) in choices.iter().enumerate() {
let star = if Some(p.as_str()) == 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 Some(raw) = read_line()? else {
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
};
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()?.unwrap_or_default();
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 Some(raw) = read_line()? else {
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
};
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()?.unwrap_or_default();
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 wizard_offers_every_registry_provider() {
// Regression (v1.1.8): the wizard used a hardcoded CHOICES list and
// the QwenCloud providers added to the registry never appeared.
// The list must now track the merged registry + ollama + custom.
let app = app_with("");
let c = choices(&app);
for p in [
"anthropic",
"openai",
"deepseek",
"google",
"openrouter",
"qwen-cloud",
"qwen-cloud-token-plan",
"ollama",
"custom",
] {
assert!(c.iter().any(|x| x == p), "wizard must offer {p}: {c:?}");
}
// User-defined providers show up too, ollama is never duplicated.
let app2 = app_with(
" default_provider: anthropic\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n my-llm:\n base_url: https://llm.internal/v1\n",
);
let c2 = choices(&app2);
assert_eq!(c2.iter().filter(|x| *x == "ollama").count(), 1, "{c2:?}");
assert!(c2.iter().any(|x| x == "my-llm"), "{c2:?}");
}
#[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")
);
}
}
+472
View File
@@ -0,0 +1,472 @@
//! 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
//! `--code` (it prints only the command and never runs it — aichat is
//! invoked without `--execute`), 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")?;
// aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to
// skip the API call entirely (it echoes the request), so the old
// `--execute --dry-run` no longer yields the generated command. Plain
// chat with `--code` extracts and prints only the command (same output
// shape, works on every aichat version). aichat NEVER executes here —
// am enforces the safety policy before running the command itself.
let mut extra_args: Vec<String> = vec!["--code".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);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ const WEB: &str = r#"# Généré par 'am init --template web' — projet {projec
# Répertoire : {dir} # Répertoire : {dir}
version: "1.0" version: "1.0"
groups: groups:
dev: [claude-code, opencode, gemini-cli] dev: [claude-code, opencode, antigravity-cli]
profiles: profiles:
dev: dev:
agent: claude-code agent: claude-code
+4 -1
View File
@@ -134,7 +134,9 @@ fn index_page(app: &App) -> HttpResult {
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle); let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
// Then every {{label}} token: replaced by the localized label (the // Then every {{label}} token: replaced by the localized label (the
// French text is both the key and the fallback, so nothing survives). // French text is both the key and the fallback, so nothing survives).
while let Some(start) = html.find("{{") { let mut cursor = 0;
while let Some(rel_start) = html[cursor..].find("{{") {
let start = cursor + rel_start;
let Some(rel_end) = html[start + 2..].find("}}") else { let Some(rel_end) = html[start + 2..].find("}}") else {
break; break;
}; };
@@ -142,6 +144,7 @@ fn index_page(app: &App) -> HttpResult {
let key = html[start + 2..end].to_string(); let key = html[start + 2..end].to_string();
let translated = crate::i18n::tr_in(lang, &key); let translated = crate::i18n::tr_in(lang, &key);
html.replace_range(start..end + 2, translated); html.replace_range(start..end + 2, translated);
cursor = start + translated.len();
} }
HttpResult { HttpResult {
status: 200, status: 200,
+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}");
}