Compare commits

..
11 Commits
Author SHA1 Message Date
bruno 068940495a fix: am setup — ecrire settings.providers.<nom>.base_url + default_model (top-level 'providers:' rejete par le validateur)
release / release (push) Successful in 13m11s
release / macos (push) Canceled after 0s
Le wizard persistait le modele par defaut sous la cle 'providers.<nom>.default_model'
-> bloc 'providers:' au top-level de la config user -> 'unknown field providers'
(le registre vit sous settings.providers ; le schema exige base_url quand un
bloc provider est ecrit). Regression couverte par setup_persists_under_settings_providers.
v1.1.1
2026-08-21 07:43:26 -04:00
bruno d71c3dd34d feat: epique v1.1.0 — onboarding, copilot & roles aichat (am setup)
release / release (push) Successful in 13m21s
release / macos (push) Canceled after 0s
F1 — Wizard d'onboarding (src/setup.rs) :
- am setup : provider (anthropic, openai, deepseek, google, ollama, custom),
  token masque (rpassword) -> keyring OS (jamais en clair), modele par defaut
  (liste declaree + champ libre), ping API non-bloquant
- Declencheurs non-bloquants : am ai / am ask et banner REPL proposent
  am setup quand aucun provider n'est configure ; re-executable (mode revoir)
- settings.default_provider + providers.* ecrits via persist_setting

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

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

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

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

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

- AgentDef.detect : sonde PATH explicite, repli sur first_token sinon
- probe.rs : cache invalidé quand les tokens du catalogue changent
  (token_hash), plus besoin d'attendre un changement de PATH
- config.yaml : detect: lazycodex-ai / detect: nanobot
- tests : detect_token_prefers_explicit_binary + 432 tests verts
- v1.0.3, man pages régénérées
2026-08-20 10:53:59 -04:00
bruno 79e032b563 feat: Add Axe 13 (Shell AI) to roadmap 2026-08-20 10:45:10 -04:00
bruno d0f52eb7c0 fix: REPL — ask/serve/registry routées vers am au lieu du shell système
is_am_command() ne connaissait pas ask/serve/registry : le REPL les
envoyait au shell système ('command not found'). Ajout aux trois
points de contact (is_am_command, COMMAND_DESCRIPTIONS, banner) +
test élargi. Docs alignées sur v1.0 : README (ask/providers/registry/
serve), ROADMAP v2.3 (v0.7.0 + v1.0.0 livrés), ARCHITECTURE (arbre src
+ section copilote & plateforme), tips ask/serve/registry. Version 1.0.2,
man pages régénérées. CI release : cross-compile Linux musl + Windows
mingw sur un seul runner, macOS optionnel.
2026-08-20 10:22:21 -04:00
bruno 38d38d51e3 fix: install.sh acceptait seulement l'entrée 'am' dans l'archive de release
Le pipeline CI publie am-linux-x86_64.tar.gz avec une entrée nommée 'am-linux-x86_64' (pas 'am') : le test [ -f $tmpdir/am ] échouait et le script retombait sur cargo install depuis les sources alors que le binaire précompilé existe. Recherche flexible du premier fichier extrait (am | am-$platform-$arch | n'importe quel fichier). Vérifié : extraction réelle du tar.gz v1.0.1 → ELF statique trouvé.
2026-08-20 09:32:00 -04:00
31 changed files with 2456 additions and 82 deletions
+49 -51
View File
@@ -1,28 +1,30 @@
# Gitea Actions : pipeline de release (issue #42).
#
# Sur chaque tag v* : construit les binaires de toutes les plateformes,
# archive + publie la release Gitea avec les artefacts. Les installateurs
# (install.sh / install.ps1) et 'am self-update' consomment ces artefacts.
# Sur chaque tag v* : construit les binaires de toutes les plateformes.
# Le job principal 'release' cross-compile Linux + Windows depuis un seul
# runner ubuntu-latest, donc il n'a pas besoin de runners Windows/macOS.
# Le job 'macos' est optionnel : s'il y a un runner macos-latest, il ajoute
# les archives macOS a la release existante ; sinon l'installateur retombe
# sur cargo install --git sur macOS.
#
# Labels de runners attendus : ubuntu-latest, windows-latest, macos-latest.
# Un runner manquant laisse son job en attente ; le job linux couvre la
# majorite des cas (les artefacts windows/macos peuvent aussi etre produits
# par les scripts locaux documentes dans RELEASING.md).
# Les installateurs one-liner (install.sh / install.ps1) et 'am self-update'
# consomment les artefacts attaches a la release.
#
# Prérequis du runner : pouvoir tirer les images docker.io (rust:1.94-alpine,
# ~300 Mo) et le secret GITEA_TOKEN (jeton avec droit 'write:release')
# positionné sur le dépôt. La release existante est écrasée
# (release_overwrite).
# Cross-compilation utilisee :
# - Linux x86_64 + aarch64 : cargo-zigbuild + musl (binaires statiques)
# - Windows x86_64 : mingw-w64 + x86_64-pc-windows-gnu
#
# Prerequis du runner : pouvoir tirer l'image docker.io (rust:1.94-bookworm,
# ~1,2 Go) et le secret GITEA_TOKEN (jeton avec droit 'write:release')
# positionne sur le depot.
#
# Note disque (runner avec espace limite) :
# - il faut ~1 Go libres sur / pour tirer l'image (rust:1.94-alpine) ;
# - il faut ~2 Go libres sur / pour tirer l'image (rust:1.94-bookworm) ;
# en cas de 'no space left on device' : docker system prune -af --volumes
# sur l'HOTE du runner (pas une autre machine !) ;
# - les builds compilent dans CARGO_TARGET_DIR=/tmp (hors du volume
# workspace) avec l'incremental desactive, donc rien ne persiste ;
# - le job linux utilise cargo-zigbuild : zig (~50 Mo) fournit le linker
# croise pour les DEUX cibles musl statiques dans une seule image legere
# (rust:1.94-alpine n'embarque pas de compilateur croise aarch64).
# - zig (~50 Mo) fournit le linker croise pour Linux.
name: release
on:
@@ -35,41 +37,56 @@ concurrency:
cancel-in-progress: true
jobs:
linux:
release:
runs-on: ubuntu-latest
container: rust:1.94-alpine
container: rust:1.94-bookworm
env:
CARGO_TARGET_DIR: /tmp/am-target
CARGO_INCREMENTAL: "0"
steps:
# actions/checkout (et son post-step) s'execute via node, absent de
# l'image rust:alpine : sans nodejs le job echoue des le checkout.
# l'image rust:bookworm par defaut : sans nodejs le job echoue des le checkout.
- name: Prepare container (node for checkout action)
run: apk add --no-cache nodejs
run: apt-get update && apt-get install -y nodejs
- uses: actions/checkout@v4
- name: Install build tools (zig + cargo-zigbuild)
- name: Install build tools (zig + cargo-zigbuild + mingw)
run: |
apk add --no-cache build-base zip curl
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
apt-get update
apt-get install -y build-essential mingw-w64 zip curl
rustup target add \
x86_64-unknown-linux-musl \
aarch64-unknown-linux-musl \
x86_64-pc-windows-gnu
curl -fsSL https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz -o /tmp/zig.tar.xz
tar -xf /tmp/zig.tar.xz -C /opt
echo "/opt/zig-x86_64-linux-0.14.1" >> "$GITHUB_PATH"
cargo install cargo-zigbuild --locked
# Contourne un bug de casse dans certaines crates Windows
# (ex. windows-sys peut emettre -lKernel32 au lieu de -lkernel32).
# Le linker MinGW sous Linux est sensible a la casse.
ln -sf /usr/x86_64-w64-mingw32/lib/libkernel32.a \
/usr/x86_64-w64-mingw32/lib/libKernel32.a
- name: Build linux x86_64 + aarch64 (static musl, zig)
run: cargo zigbuild --release --locked --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl
- name: Build windows x86_64 (mingw)
run: cargo build --release --locked --target x86_64-pc-windows-gnu
- name: Stage archives
run: |
mkdir -p dist
cp "$CARGO_TARGET_DIR/x86_64-unknown-linux-musl/release/am" dist/am-linux-x86_64
cp "$CARGO_TARGET_DIR/aarch64-unknown-linux-musl/release/am" dist/am-linux-aarch64
cp "$CARGO_TARGET_DIR/x86_64-pc-windows-gnu/release/am.exe" dist/am-windows-x86_64.exe
cd dist
tar -czf am-linux-x86_64.tar.gz am-linux-x86_64
tar -czf am-linux-aarch64.tar.gz am-linux-aarch64
sha256sum am-linux-*.tar.gz > checksums.txt
zip -q am-windows-x86_64.zip am-windows-x86_64.exe
rm am-windows-x86_64.exe
sha256sum am-linux-*.tar.gz am-windows-*.zip > checksums.txt
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1
@@ -80,37 +97,16 @@ jobs:
files: |
dist/am-linux-x86_64.tar.gz
dist/am-linux-aarch64.tar.gz
dist/checksums.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true
windows:
runs-on: windows-latest
env:
CARGO_TARGET_DIR: C:/am-target
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
- name: Build windows x86_64
run: cargo build --release --locked
- name: Stage archive
run: |
New-Item -ItemType Directory -Force dist | Out-Null
Compress-Archive -Path C:/am-target/release/am.exe -DestinationPath dist/am-windows-x86_64.zip -Force
(Get-FileHash dist/am-windows-x86_64.zip -Algorithm SHA256).Hash.ToLower() | Out-File dist/checksums.txt
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: |
dist/am-windows-x86_64.zip
dist/checksums.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true
macos:
# Optionnel : ajoute les archives macOS si un runner macos-latest est
# disponible. Sinon ce job reste en attente, mais le job release a deja
# publie Linux + Windows.
needs: release
runs-on: macos-latest
env:
CARGO_TARGET_DIR: /tmp/am-target
@@ -132,7 +128,7 @@ jobs:
cd dist
tar -czf am-macos-x86_64.tar.gz am-macos-x86_64
tar -czf am-macos-aarch64.tar.gz am-macos-aarch64
shasum -a 256 am-*.tar.gz > checksums.txt
shasum -a 256 am-macos-*.tar.gz > checksums-macos.txt
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1
@@ -140,6 +136,8 @@ jobs:
files: |
dist/am-macos-x86_64.tar.gz
dist/am-macos-aarch64.tar.gz
dist/checksums.txt
dist/checksums-macos.txt
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
release_overwrite: true
# false = ajoute les assets macOS a la release creee par le job
# principal, sans ecraser Linux + Windows.
release_overwrite: false
+98 -2
View File
@@ -15,6 +15,7 @@
- 🚀 Les démarrer, arrêter, redémarrer en avant-plan ou en arrière-plan
- 🔍 Observer leur état, leurs logs, leurs statistiques d'utilisation
- 🛠️ Gérer dépendances, alias, groupes, profils, secrets, favoris et annotations
- ⚡ Exécuter des actions shell via langage naturel avec un agent léger (`am ai`)
Le tout sans toucher au système : chaque agent est installé dans un répertoire utilisateur isolé.
@@ -35,6 +36,7 @@ flowchart TB
APP["App<br/>contexte partagé"]
CLI_DEF["cli.rs<br/>définition des commandes"]
CMD["commands/<br/>dispatch"]
SHELL_AI["shell_ai.rs<br/>agent shell léger"]
end
subgraph DATA["💾 Données persistantes"]
@@ -62,7 +64,9 @@ flowchart TB
CLI --> APP
REPL --> APP
TUI --> APP
WEB --> APP
APP --> CLI_DEF
APP --> SHELL_AI
APP --> CONFIG
APP --> CATALOG
APP --> STATE
@@ -76,6 +80,7 @@ flowchart TB
CMD --> DASH
CMD --> STATS
CMD --> SESSIONS
SHELL_AI --> CMD
```
---
@@ -131,6 +136,7 @@ src/
├── app.rs 🧰 Contexte App (config, state, paths, logger, theme)
├── config.rs ⚙️ Schéma YAML, chargement, fusion, validation
├── catalog.rs 🔍 Index agents + recherche fuzzy + suggestions
├── catalog_remote.rs 🌐 Catalogues distants (fetch, cache, includes)
├── state.rs 💾 Base JSON des installations et annotations
├── events.rs 📝 Journal d'événements JSONL
├── runner.rs 🏃 Trait d'exécution système / mock
@@ -149,15 +155,34 @@ src/
├── tables.rs 📋 Rendu tabulaire
├── theme.rs 🎨 Thèmes de couleur (REPL et tableaux)
├── web.rs 🌐 Serveur HTTP local (127.0.0.1) + API JSON + frontend embarqué
├── serve.rs 🚀 API HTTP + WebSocket authentifiée (pilotage à distance, issue #80)
├── frontend/ 📄 index.html — dashboard web (HTML5 + CSS + JS vanilla, include_str!)
├── history.rs ⏪ Historique des commandes
├── sessions.rs 📅 Registre des sessions
├── projects.rs 🗂️️ Agrégation par projet
├── hooks.rs 🪝 Hooks de cycle de vie
├── secrets.rs 🔒 Gestion des secrets (keyring OS)
├── providers.rs 🏷️ Registre de providers LLM (base_url, modèles, défaut, @secret)
├── registry.rs 📦 Registre communautaire de catalogues (manifeste + sha256)
├── ask.rs 💬 Langage naturel → commandes am (règles locales + LLM optionnel)
├── sandbox.rs 🛡️ Profils sandbox par agent (allowlist, périmètre, réseau)
├── telemetry.rs 📈 Télémétrie anonyme opt-in (compteurs agrégés)
├── lab.rs 🧪 Benchmark d'agents (tâches YAML versionnables)
├── playbook.rs ▶️ Rejeu pas à pas d'une séquence d'historique
├── plugins.rs 🔌 Scripts d'extension sur les événements (contrat JSON)
├── models.rs 🤖 Inventaire des modèles locaux (ollama, llama.cpp, LM Studio)
├── shell_ai.rs ⚡ Agent shell léger : langage naturel → action shell
├── sync.rs 🔄 Push git de l'état (journal, sessions, config)
├── agent_config.rs ⚙️ Adaptateurs de configuration post-install (TOML/YAML/JSON/key=value)
├── costs.rs 💰 Coûts estimés par agent (tokens in/out, modèles de prix)
├── automation.rs ⚙️ Services système + tâches planifiées (systemd/launchd/schtasks)
├── backup.rs 💾 Sauvegardes (update --rollback, migrate)
├── doctor.rs 🩺 Diagnostics environnement + --fix
├── nav.rs 🧭 Tables de navigation (ls/dir)
├── i18n.rs 🌍 Catalogue de traductions FR/EN (--lang, AM_LANG, LANG)
├── version.rs 🏷️ Informations de version
├── commands/ 📦 35 modules, un par commande
└── installers/ 📦 9 installateurs spécialisés
├── commands/ 📦 51 modules, un par commande
└── installers/ 📦 8 installateurs spécialisés
```
---
@@ -435,6 +460,26 @@ flowchart TB
| `am version` | Version et build |
| `am` (sans commande) | REPL interactif |
### 🤖 Copilote & plateforme (v0.7.0 / v1.0)
| Commande | Description |
|----------|-------------|
| `am ask "<demande>"` | Langage naturel → commande(s) am : règles locales FR/EN hors-ligne, raffinement LLM optionnel (`settings.ask`), confirmation avant exécution |
| `am providers list/add/remove/set-token` | Registre LLM centralisé (base_url, modèles, clé par provider au keyring, résolution `@secret`) |
| `am registry publish/search/install` | Registre communautaire de catalogues (Gitea, manifeste + checksum sha256 vérifié) |
| `am serve --token [--port]` | API HTTP + WebSocket authentifiée : stats, run, start, stop, ask — rate limiting par IP, TLS derrière reverse proxy |
| `am web [--port]` | Dashboard web local en lecture seule (127.0.0.1), contrats `--json` réutilisés |
| `am ai "<prompt>" [--exec] [--files <path>]` | **Shell AI** : langage naturel → commande/action shell via agent léger (AIChat) ; `--dry-run` par défaut, confirmation avant exécution |
| `am lab --agents a,b --task <f>` | Benchmark comparatif (durée, exit, coût) sur tâches YAML versionnables |
| `am sync [--message]` | Sauvegarde git de l'état (journal, sessions, config — secrets exclus) |
| `am migrate export/import` | Bundle de transfert machine A → B (config + état + historique) |
| `am schedule add/list/remove/run` | Planification de commandes am (cron / Task Scheduler, issue #56) |
| `am service install <agent>` | Service système (systemd / launchd / tâche Windows, autostart) |
| `am monitor [--json]` | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils |
| `am models [--prune]` | Inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| `am audit` | Qui a modifié quoi, quand (checksums config + journal) |
| `am plugins [--test <nom>]` | Scripts d'extension sur les événements (contrat JSON stdin/stdout) |
---
## 🌐 Options globales
@@ -673,6 +718,57 @@ flowchart LR
---
## ⚡ Shell AI
`am ai` (alias `am shell`) est une commande dédiée aux **actions shell via langage naturel**. Elle repose sur un agent léger (par défaut **AIChat**, installé comme n'importe quel autre agent via le catalogue) et réutilise le registre de providers LLM (`providers.rs`) pour choisir le modèle le plus rapide/cheap.
### Flux d'exécution
```mermaid
sequenceDiagram
autonumber
participant User
participant cli as cli.rs
participant shell_ai as shell_ai.rs
participant catalog as catalog.rs
participant providers as providers.rs
participant runner as runner.rs
participant aichat as aichat (agent)
User->>cli: am ai "traite les JSON"
cli->>shell_ai: parse args (--exec, --files)
shell_ai->>catalog: agent "aichat" installé ?
catalog-->>shell_ai: Ok / install
shell_ai->>providers: provider & modèle par défaut
providers-->>shell_ai: config (ollama / cheap cloud)
shell_ai->>shell_ai: injecte cwd + fichiers (--files)
shell_ai->>runner: exec aichat -f . -e "..."
runner->>aichat: lancement processus
aichat-->>runner: commande générée / exécutée
runner-->>shell_ai: output + exit code
shell_ai->>shell_ai: journalise événement shell_ai
shell_ai-->>User: résultat ou confirmation
```
### Sécurité
| Règle | Détail |
|---|---|
| `--dry-run` par défaut | Aucune commande modifiante n'est exécutée sans confirmation |
| Classification risk/certainty | Inspiré d'AI CLI : chaque commande est classée `safe` ou `risky` |
| Confirmation utilisateur | Les commandes `risky` demandent une validation explicite |
| Mode local possible | Support d'Ollama via `providers.rs` pour ne pas sortir les données |
### Dépendances
- `src/shell_ai.rs` : parsing du prompt, gestion des flags, appel à l'agent
- `src/providers.rs` : résolution du provider/modèle
- `src/runner.rs` : exécution du binaire `aichat`
- `src/events.rs` : journalisation `shell_ai` dans le journal JSONL
- `config.yaml` : définition de l'agent `aichat` + alias `ai`
---
## 📚 Références
- `src/lib.rs:4` : documentation d'architecture du crate
Generated
+23 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "1.0.1"
version = "1.1.1"
dependencies = [
"anyhow",
"base64",
@@ -36,6 +36,7 @@ dependencies = [
"nu-ansi-term",
"ratatui",
"reedline",
"rpassword",
"semver",
"serde",
"serde_json",
@@ -1779,6 +1780,27 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "1.0.1"
version = "1.1.1"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -38,6 +38,7 @@ toml = "0.8"
ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2"
keyring = { version = "3", features = ["windows-native", "linux-native"] }
rpassword = "7"
which = "7"
zip = "0.6"
crossterm = "0.29"
+36 -4
View File
@@ -1,7 +1,7 @@
# 🚀 agent-manager — vos agents IA, gérés comme des apps
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **72 agents connus**)
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **77 agents connus**)
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
Go, Rust…) prise en charge pour vous.
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
## ✨ Pourquoi am ?
- 🗂️ **Catalogue de 72 agents prêts à l'emploi** — descriptions, méthodes
- 🗂️ **Catalogue de 77 agents prêts à l'emploi** — descriptions, méthodes
d'installation, dépendances et commandes de démarrage déjà configurées.
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
@@ -48,6 +48,32 @@ plateforme, il compile automatiquement depuis les sources.
> 🔄 **Mise à jour** : relancez simplement la même commande.
### 🚀 Premier lancement (onboarding v1.1.0)
Au premier lancement (ou dès que vous tapez `am ai` / `am ask` sans
provider configuré), `am` vous propose le wizard d'onboarding :
am setup
Il vous guide en 3 étapes :
1. **Provider** : anthropic, openai, deepseek, google, ollama (local) ou
custom (base URL).
2. **Token API** : saisie masquée, stockée dans le trousseau OS (keyring)
— jamais écrite en clair dans la config.
3. **Modèle par défaut** : choisi parmi les modèles déclarés du provider.
Le wizard installe ensuite **aichat** (le moteur de `am ai`) si vous
acceptez, et génère **6 rôles copilot** pour aichat (`am setup --roles`
pour les régénérer) :
| Rôle | Rôle |
|---|---|
| `am-copilot` — guide & catalogue am | `am-dev` — code, git, tests |
| `am-operator` — shell sécurisé | `am-analyst` — coûts, logs, stats |
| `am-do` — exécution pure pour `--exec` | `am-orchestrator` — groupes, lab |
Utilisation : `am ai --role am-operator "compresse les fichiers JSON"`.
---
## 🎬 Vos 3 premières commandes
@@ -78,11 +104,16 @@ plateforme, il compile automatiquement depuis les sources.
| am list --all | tout le catalogue, avec l'état de chacun |
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
| am ask "<demande>" | langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (settings.ask), confirmation avant exécution — ex: am ask "installe claude et lance-le" |
| am ai "<demande>" [--exec] [--files <path>] [--role <rôle>] | langage naturel → action shell via AIChat (alias: am shell) : conversationnel par défaut ; --exec génère la commande, la classe safe/risky et applique la politique de sécurité (settings.shell_ai, dry-run par défaut — --yes pour exécuter) ; --role = rôle copilot am-* (am setup) |
| am setup [--roles] | wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat + rôles copilot (v1.1.0) |
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs |
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
| am providers list / add / set-token | registre LLM centralisé : base_url, modèles par provider, clé dans le trousseau (jamais en clair, résolue via @secret) |
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
| am registry publish/search/install | registre communautaire de catalogues (Gitea) : publication, recherche, installation avec checksum sha256 vérifié et décision de confiance explicite |
| am audit | qui a modifié quoi, quand (checksums config + journal) |
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
@@ -127,7 +158,7 @@ Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AG
#### 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é (77 agents) avec `~/.config/agent-manager/config.yaml` et un éventuel `./agent-manager.yaml`. Vos définitions remplacent les entrées par défaut du même nom.
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place.
3. **Choix de la méthode** — chaque agent déclare une méthode principale et des alternatives. `--method <nom>` force le choix ; sinon `am` prend la première disponible. Pour `am update`, c'est la méthode utilisée lors de l'installation initiale qui est reconstruite.
4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
@@ -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 open <agent> | ouvre le répertoire d'installation dans l'explorateur |
| am completion <shell> [--installed] | script de complétion (bash, zsh, fish, powershell, elvish) ; --installed complète dynamiquement les agents installés, alias et groupes |
| am serve --token [--port <p>] | API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask…) — rate limiting par IP, TLS via reverse proxy |
| am self-update | met à jour am lui-même (si configuré) |
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
| am | shell interactif : bannière, passerelle système, Tab et historique |
@@ -351,7 +383,7 @@ supprimez aussi ces emplacements.)
1. --config <fichier>
2. ./agent-manager.yaml (répertoire courant)
3. ~/.config/agent-manager/config.yaml
4. catalogue embarqué (72 agents)
4. catalogue embarqué (77 agents)
Vos définitions **complètent ou surchargent** le catalogue par nom.
+164 -7
View File
@@ -1,6 +1,7 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.2** · Statut : propositions, non engagées
> **Version du document : 3.0** · Statut : phases 0 à 3 livrées
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.0
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
>
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
@@ -18,8 +19,57 @@
> (#47–#75), 250+ tests. am web, i18n, services, schedule, lab, sync,
> migrate, models, plugins d'événements… Binaires publiés sur Gitea.
>
> 🔨 **Phase v0.7.0 en cours (priorité P0)** : providers & configuration
> automatisée (épique #87) — livrée AVANT la Phase 3 (v1.0), voir section 7 bis.
> ✅ **Phase v0.7.0 livrée (2026-08-19)** : 5/5 issues clôturées (#88–#92) —
> registre de providers LLM (am providers), secrets partagés par provider,
> flags --provider/--model/--no-config à l'install, configuration
> post-install (bloc config:).
>
> ✅ **Phase 3 livrée en v1.0.0 (2026-08-20)** : 5/5 issues clôturées
> (#76–#80) — télémétrie opt-in, am registry, am ask, profils sandbox,
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
> archives, prompt REPL avec shell actif.
>
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect`
> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python),
> plus de faux « external » ; docs alignées.
>
> 🔧 **Maintenance v1.0.4 (2026-08-20)** : catalogue enrichi — 5 agents
> **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub
> Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI
> (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents.
>
> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée**
> (issues #96 #97) — langage naturel → action shell via AIChat : mode
> conversationnel (aichat -f <ctx>), mode --exec avec pipeline de sécurité
> (génération aichat --dry-run, classification safe/risky + certainité,
> politique dry-run par défaut configurable settings.shell_ai, confirmation
> y/N pour les commandes risky, exécution via le shell de l'utilisateur),
> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements
> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée.
>
> 🔧 **Maintenance v1.0.6 (2026-08-20)** : installateur Windows corrigé —
> `install.ps1` n'acceptait que l'entrée `am.exe` dans l'archive de release
> (la CI et les archives manuelles utilisent `am-windows-x86_64.exe`) →
> repli systématique sur `cargo install` au lieu du binaire précompilé.
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
> les deux entrées pour la compatibilité maximale. Chemin du message
> cargo install corrigé (`.cargo\bin\am.exe`).
>
> 🚀 **Épique v1.1.0 (2026-08-20)** : **Onboarding, Copilot & Rôles aichat**
> (Axe 14) — `am setup` (wizard provider + token keyring + modèle + ping),
> installation automatique d'aichat (wizard + `am ai` + installateurs),
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
>
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
> validateur : « unknown field `providers` »). Le registre vivant sous
> `settings.providers`, le wizard écrit désormais
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
> base_url quand un bloc provider est écrit). Test de régression
> `setup_persists_under_settings_providers`.
---
@@ -41,7 +91,7 @@
## 1. 📌 Résumé exécutif
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 77 agents au
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
@@ -103,7 +153,7 @@ quelques jours, à caler avant ou pendant la construction du journal.*
| Brique actuelle | Fichier | Limite aujourd'hui |
|---|---|---|
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
| Catalogue 77 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
| Installation (9 méthodes), dépendances OS | src/installers/, src/deps.rs, src/toolchain.rs | solide — rien à redire |
| État local | src/state.rs (state.json v1) | installations + PID courant uniquement, **aucun historique** |
| Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
@@ -335,6 +385,110 @@ consultable, reprenable, archivable.
---
### Axe 13 — ⚡ Shell AI & Action ⭐
🎯 Transformer une commande en langage naturel en une action shell exécutée par un agent léger, rapide et contextuel, sans démarrer un gros coding agent.
**Contexte :** `am` gère aujourd'hui ~77 agents IA, principalement des *coding agents*, des assistants et des outils SaaS. Les shell assistants sont maintenant au catalogue (AIChat, ShellGPT, Fabric, Shell AI, AI CLI — v1.0.4) : légers, rapides, conçus pour transformer une phrase en commande ou action sur le système de fichiers local (ex. *"traite les fichiers JSON du dossier courant pour extraire les clés uniques"*).
---
#### 13.1 Agents shell AI découverts (✅ tous ajoutés au catalogue — v1.0.4, issues #94 #95)
| Outil | Repo | Langage | Force | Maturité |
|---|---|---|---|---|
| ✅ **AIChat** | `sigoden/aichat` | Rust | Shell assistant natif, RAG, agents, fichiers/répertoires, 20+ providers | **10.4k stars**, très actif |
| ✅ **ShellGPT** | `TheR1D/shell_gpt` | Python | Génère/exécute commandes shell, code, docs | Très connu, mature |
| ✅ **Fabric** | `danielmiessler/fabric` | Go | Patterns AI (summarize, extract wisdom), CLI | Très populaire, orienté contenu/texte |
| ✅ **Shell AI** | `nishant9083/shell-ai` | TypeScript | Agent ReAct local via Ollama, MCP, filesystem tools | Plus récent, prometteur mais jeune |
| ✅ **AI CLI** | `kriserickson/ai-cli` | Go | Natural language → commandes shell avec safety policy (`risk` / `certainty`) | Petit, simple, moins connu |
---
#### 13.2 Recommandation : AIChat (`sigoden/aichat`)
**Choix privilégié pour la fonction Shell AI.**
| Avantage | Détails |
|---|---|
| **Léger & rapide** | Rust, binaire unique, cold start rapide, idéal pour RPi 4 |
| **Shell Assistant natif** | Génère et exécute des commandes shell à partir du langage naturel |
| **Multi-provider** | OpenAI, Claude, Gemini, DeepSeek, Groq, Ollama, OpenRouter… |
| **Passage de contexte** | `aichat -f .` injecte le dossier ou les fichiers dans le prompt |
| **Mode exécution** | `aichat -e "..."` exécute directement, mode conversationnel sinon |
| **Facilité d'intégration** | Release binaire GitHub, installable via `binary` dans le catalogue |
Exemples d'usage ciblés :
```bash
# Lister et traiter les fichiers JSON du dossier courant
aichat -f . -e "liste tous les fichiers JSON et extrait les clés uniques"
# Résumer un dossier de fichiers
aichat -f . "résume les données de ces fichiers JSON"
```
---
#### 13.3 Fonctionnalités prévues
| Fonctionnalité | Description | Effort | Phase |
|---|---|---|---|
| ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 |
| ✅ **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 |
| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 |
| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 |
| **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 |
---
#### 13.4 Risques & garde-fous
| Risque | Mitigation |
|---|---|
| Exécution automatique de commandes dangereuses | `--dry-run` par défaut, confirmation obligatoire avant toute commande `risky` |
| Coût API récurrent | Modèle cheap par défaut (`gpt-4.1-mini`, `gemini-flash`) ou Ollama local |
| Fuite de données sensibles | Support du mode local Ollama, pas d'envoi hors du provider configuré |
| Installation lente sur RPi | Utiliser l'installateur `binary` GitHub Releases plutôt que `cargo` |
---
#### 13.5 Idées sœurs (à intégrer ou lier à d'autres axes)
| Commande | Description | Axe lié |
|---|---|---|
| `am summarize <fichier/dossier>` | Résume un fichier ou un dossier via AIChat/Fabric | Axe 13 |
| `am explain <commande>` | Explique une commande shell avant exécution | Axe 13 |
| `am commit` | Génère un message de commit depuis `git diff` | Axe 13 / Axe 3 |
| `am review` | Review rapide d'un diff ou d'une PR | Axe 7 |
| `am translate <fichier> --to en` | Traduit un fichier markdown/doc | Axe 13 |
| `am ask-code "..."` | Pose une question sur le codebase sans lancer un gros agent | Axe 7 |
| `am doc <dossier>` | Génère un README/doc à partir du code | Axe 13 / Axe 7 |
| `am fix` | Corrige une commande shell qui a échoué | Axe 13 |
| `am note` | Extrait des action items d'un texte/réunion | Axe 10 |
| `am search-web "..."` | Recherche web rapide et réponse synthétisée | Axe 7 |
---
## 14. 🚀 Onboarding & Copilot aichat (épique v1.1.0)
> Objectif : réduire le **Time-To-First-Value à 0** — rendre `am` opérationnel
> pour l'IA locale dès la première seconde, et faire d'aichat le copilot de
> agent-manager. Livré en **v1.1.0 (2026-08-20)**.
| Fonctionnalité | Description | Statut |
|---|---|---|
| ✅ **Wizard d'onboarding (`am setup`)** | Provider (anthropic, openai, deepseek, google, ollama, custom), token masqué → trousseau OS (keyring, jamais en clair), modèle par défaut, ping API non-bloquant. Déclenché par `am ai`/`am ask`/banner REPL quand aucun provider n'est configuré (message discret, jamais bloquant), ré-exécutable à la main (mode revoir) | ✅ v1.1.0 |
| ✅ **Installation automatique d'aichat** | Étape du wizard « Installer le moteur IA (aichat) ? [Y/n] » → `am install aichat` (catalogue) ; `am ai` sans aichat propose l'installation ; install.ps1/install.sh enchaînent sur `am setup` si stdin interactif | ✅ v1.1.0 |
| ✅ **Rôles copilot aichat (am-*)** | 6 agents générés dans `~/.config/aichat/agents/` (ou %APPDATA%\\aichat\\agents) : am-copilot (guide & catalogue), am-operator (shell sécurisé), am-dev (code & git), am-do (exécution pure pour --exec), am-analyst (coûts/logs/stats), am-orchestrator (groupes/lab). Prompts bilingues (FR par défaut), privilégient les contrats `--json` | ✅ v1.1.0 |
| ✅ **`am ai --role <nom>`** | Rôle copilot passé à aichat (`--agent`) ; validation du fichier généré ; complétion REPL des rôles am-* ; config aichat créée (provider + modèle) seulement si absente | ✅ v1.1.0 |
| ✅ **Settings dédiés** | `settings.shell_ai` (default_safety dry-run/confirm/auto, risky_patterns) documenté dans config.yaml ; module `src/setup.rs` + `src/roles.rs` | ✅ v1.1.0 |
---
## 8. 🚀 Jalons versionnés
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
@@ -509,5 +663,8 @@ configuration manuelle (tokens au keyring, provider/modèle par défaut).
---
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
La phase 0 est livrée (v0.3.0) ; les découpages des phases 1 (v0.4.0),
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).*
Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers,
2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe).*
+83
View File
@@ -64,6 +64,17 @@ settings:
# sources:
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
# author: bruno
# am ai (#96 #97) : politique de sécurité Shell AI. dry-run par défaut —
# aucune commande modifiante n'est exécutée sans confirmation explicite
# (--yes). `confirm` demande validation pour les commandes risky ;
# `auto` exécute tout (déconseillé). Les motifs supplémentaires s'ajoutent
# aux patterns intégrés (rm -rf, dd if, mkfs, chmod -R 777, ...).
# shell_ai:
# default_safety: dry-run # dry-run | confirm | auto
# risky_patterns:
# - "rm -rf"
# - "> /dev"
# - "dd if"
# Profil sandbox par agent (#79) : commandes autorisées (binaires), répertoires
# de travail et politique réseau. Désactivé par défaut (mode non sandboxé).
# Les tentatives refusées sont journalisées (events sandbox) pour l'audit.
@@ -108,6 +119,7 @@ aliases:
gjc: gajae-code
tiny: tiny-agents
continue: continue-cli
ai: aichat
# --- Groups (start/stop/restart several agents together) ----------------------
groups:
@@ -798,6 +810,9 @@ agents:
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: "npx lazycodex-ai"
# Sonde PATH explicite : le premier mot de `run` est npx (interpréteur) —
# sans `detect`, npx sur le PATH suffirait à déclarer l'agent « external ».
detect: lazycodex-ai
note: "s'intègre dans Codex (nécessite @openai/codex)"
tags: [harness, codex, plan]
@@ -870,6 +885,71 @@ agents:
note: "étapes de build spécifiques au repo — consultez son README"
tags: [simulator, orchestration]
# ---------------------------------------------------------------- Shell AI
- name: aichat
display_name: "AIChat"
description: "All-in-one LLM CLI avec Shell Assistant, Chat-REPL, RAG, AI Tools & Agents et support fichiers/répertoires (-f). Idéal pour exécuter des micro-tâches shell via langage naturel (issue #94)."
category: shell-ai
website: https://github.com/sigoden/aichat
install:
type: binary
repo: sigoden/aichat
binary: aichat
run: aichat
tags: [shell, rust, multi-provider, rag, local, fast]
- name: ai-cli
display_name: "AI CLI"
description: "Traduit le langage naturel en commandes shell (OpenAI, OpenRouter ou serveur local) avec politique de sécurité (risk / certainty) avant exécution (issue #95)."
category: shell-ai
website: https://github.com/kriserickson/ai-cli
install:
type: binary
repo: kriserickson/ai-cli
binary: ai
run: ai
tags: [shell, go, safety, multi-provider]
- name: fabric
display_name: "Fabric"
description: "Framework open source pour augmenter les humains avec l'IA : patterns réutilisables (summarize, extract wisdom, analyse de contenu), CLI Go 43k+ stars (issue #95)."
category: shell-ai
website: https://github.com/danielmiessler/fabric
install:
type: go
package: github.com/danielmiessler/fabric/cmd/fabric@latest
dependencies:
- { name: go, min_version: "1.22.0", install_hint: "https://go.dev/dl" }
run: fabric
tags: [patterns, go, content]
- name: shell-ai
display_name: "Shell AI"
description: "Agent ReAct local-first propulsé par Ollama : zéro réseau, outils filesystem, exécution de commandes shell et intégration MCP (issue #95)."
category: shell-ai
website: https://github.com/nishant9083/shell-ai
install:
type: npm
package: "@shell-ai/cli"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: shell-ai
note: "nécessite Ollama et un modèle (ollama pull gpt-oss) — agent jeune (15 stars)"
tags: [shell, ollama, local, mcp, typescript]
- name: shellgpt
display_name: "ShellGPT"
description: "Outil de productivité CLI propulsé par LLM (GPT-5…) : génère et exécute des commandes shell, écrit du code et des docs, 12k+ stars (issue #95)."
category: shell-ai
website: https://github.com/TheR1D/shell_gpt
install:
type: pip
package: shell-gpt
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: sgpt
tags: [shell, python, multi-provider]
# ----------------------------------------------------- Écosystème OpenClaw
- name: openclaw
display_name: "OpenClaw"
@@ -895,6 +975,9 @@ agents:
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: "python -m nanobot"
# Sonde PATH explicite : le premier mot de `run` est python (interpréteur) —
# sans `detect`, /usr/bin/python suffirait à déclarer l'agent « external ».
detect: nanobot
tags: [python, lightweight]
- name: zeroclaw
+11 -1
View File
@@ -24,7 +24,7 @@ function Install-FromSource {
Write-Host "Installation depuis les sources (cargo install --git $BaseUrl.git) ..."
cargo install --git "$BaseUrl.git" --locked
if ($LASTEXITCODE -ne 0) { Fail "cargo install a echoue" }
$bin = Join-Path $env:USERPROFILE ".cargo\binam.exe"
$bin = Join-Path $env:USERPROFILE ".cargo\bin\am.exe"
Write-Host ""
Write-Host "OK - am est installe dans $bin"
Write-Host "Essayez : am list | am doctor (mise a jour : relancez ce script)"
@@ -47,7 +47,10 @@ if ($arch -eq "x86_64") {
$tmpDir = Join-Path $env:TEMP "am-$tag"
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $tmp -DestinationPath $tmpDir -Force
# L'entrée de l'archive varie selon le pipeline : « am.exe » ou
# « am-windows-x86_64.exe » (nom de l'asset). Accepter les deux.
$exe = Join-Path $tmpDir "am.exe"
if (-not (Test-Path $exe)) { $exe = Join-Path $tmpDir "am-windows-x86_64.exe" }
if (Test-Path $exe) {
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
Copy-Item $exe (Join-Path $binDir "am.exe") -Force
@@ -75,6 +78,13 @@ if (-not $InstallDir) {
$env:Path = "$binDir;$env:Path"
& (Join-Path $binDir "am.exe") --version
Write-Host ""
# Épique v1.1.0 : wizard d'onboarding (provider + token + aichat + rôles)
# uniquement quand stdin est un terminal interactif (pas en CI).
if ([Console]::IsInputRedirected -eq $false) {
Write-Host "Configuration initiale (provider, token, aichat) :"
& (Join-Path $binDir "am.exe") setup
}
Write-Host ""
Write-Host "OK - 'am' est installe. Essayez :"
Write-Host " am list"
Write-Host " am doctor"
+26 -3
View File
@@ -22,6 +22,13 @@ case "$arch" in
*) fail "architecture non supportée: $arch" ;;
esac
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$os" in
linux) platform="linux" ;;
darwin) platform="macos" ;;
*) fail "système non supporté: $os" ;;
esac
install_from_source() {
if ! command -v cargo >/dev/null 2>&1; then
fail "pas de binaire précompilé pour $arch et cargo est absent — installez Rust (https://rustup.rs) puis relancez"
@@ -45,14 +52,23 @@ fi
installed=""
if [ -n "$tag" ] && command -v curl >/dev/null 2>&1; then
asset="am-linux-$arch.tar.gz"
asset="am-$platform-$arch.tar.gz"
url="$BASE_URL/releases/download/$tag/$asset"
echo "Téléchargement de $url …"
if curl -fsSL --connect-timeout 20 -o "$tmpdir/am.tar.gz" "$url"; then
tar -xzf "$tmpdir/am.tar.gz" -C "$tmpdir" 2>/dev/null || true
if [ -f "$tmpdir/am" ]; then
# L'entrée de l'archive varie selon le pipeline CI : « am » ou
# « am-linux-x86_64 » (nom de la release). Prendre le premier fichier
# extrait.
bin=""
for cand in "$tmpdir/am" "$tmpdir/am-$platform-$arch" "$tmpdir/am-$arch" "$tmpdir"/*; do
if [ -f "$cand" ] && [ -z "$bin" ]; then
bin="$cand"
fi
done
if [ -n "$bin" ]; then
mkdir -p "$INSTALL_DIR"
cp "$tmpdir/am" "$INSTALL_DIR/am"
cp "$bin" "$INSTALL_DIR/am"
chmod +x "$INSTALL_DIR/am"
installed=1
fi
@@ -77,6 +93,13 @@ esac
"$INSTALL_DIR/am" --version || true
echo ""
# Épique v1.1.0 : wizard d'onboarding (provider + token + aichat + rôles)
# uniquement quand stdin est un terminal interactif (pas en CI).
if [ -t 0 ]; then
echo "Configuration initiale (provider, token, aichat) :"
"$INSTALL_DIR/am" setup
fi
echo ""
echo "OK — 'am' est installé. Essayez :"
echo " am list"
echo " am doctor"
+31
View File
@@ -0,0 +1,31 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-ai 1 "ai "
.SH NAME
ai \- Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.SH SYNOPSIS
\fBai\fR [\fB\-e\fR|\fB\-\-exec\fR] [\fB\-f\fR|\fB\-\-files\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-\-role\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIPROMPT\fR>
.SH DESCRIPTION
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.SH OPTIONS
.TP
\fB\-e\fR, \fB\-\-exec\fR
Execute the generated shell command (after the safety policy)
.TP
\fB\-f\fR, \fB\-\-files\fR \fI<PATH>\fR
Files or directories passed as context (repeatable; default: the current directory)
.TP
\fB\-\-provider\fR \fI<ID>\fR
Provider of the registry used by aichat (env vars + model)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Force a model (aichat \-\-model)
.TP
\fB\-\-role\fR \fI<ROLE>\fR
Use one of the generated copilot roles (am\-copilot, am\-operator, am\-dev, am\-do, am\-analyst, am\-orchestrator — issue v1.1.0 F3)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIPROMPT\fR>
The request in natural language
+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
.el .ds Aq '
.TH am 1 "am 1.0.0"
.TH am 1 "am 1.1.0"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -133,6 +133,12 @@ Suggest agents matching a query, boosted by real usage (issue #61)
am\-ask(1)
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.TP
am\-ai(1)
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.TP
am\-setup(1)
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
.TP
am\-start(1)
Start an agent (foreground by default, or detached with \-\-background)
.TP
@@ -244,4 +250,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v1.0.0
v1.1.0
+11 -2
View File
@@ -12,9 +12,18 @@ $zip = "dist/am-windows-x86_64.zip"
Remove-Item $zip -ErrorAction SilentlyContinue
Compress-Archive -Path target/release/am.exe -DestinationPath $zip
function Get-Sha256Net([string]$path) {
$stream = [System.IO.File]::OpenRead($path)
try {
$sha = [System.Security.Cryptography.SHA256]::Create()
$bytes = $sha.ComputeHash($stream)
return ([BitConverter]::ToString($bytes) -replace '-', '').ToLower()
} finally { $stream.Close() }
}
Write-Host ""
Write-Host "Artefacts:"
Get-ChildItem dist | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
Get-ChildItem dist -File | ForEach-Object {
$hash = Get-Sha256Net $_.FullName
Write-Host (" {0,-32} {1} sha256:{2}" -f $_.Name, $_.Length, $hash)
}
+1
View File
@@ -408,6 +408,7 @@ mod tests {
install: None,
dependencies: vec![],
run: Some("x".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
+42
View File
@@ -214,6 +214,15 @@ pub enum Command {
#[arg(long)]
yes: bool,
},
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
#[command(alias = "shell")]
Ai(AiArgs),
/// Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
Setup {
/// Only (re)generate the am-* copilot roles for aichat
#[arg(long)]
roles: bool,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -700,6 +709,39 @@ pub struct WebArgs {
pub no_open: bool,
}
/// Arguments of am ai (issues #96 #97): natural language → shell action
/// via AIChat. The global flags --dry-run and --yes/-y apply as well.
#[derive(Args, Debug, Clone, Default)]
pub struct AiArgs {
/// The request in natural language
#[arg(value_name = "PROMPT", num_args = 1.., required = true)]
pub prompt: Vec<String>,
/// Execute the generated shell command (after the safety policy)
#[arg(short = 'e', long, action = ArgAction::SetTrue)]
pub exec: bool,
/// Files or directories passed as context (repeatable; default: the
/// current directory)
#[arg(short = 'f', long = "files", value_name = "PATH", action = ArgAction::Append)]
pub files: Vec<std::path::PathBuf>,
/// Provider of the registry used by aichat (env vars + model)
#[arg(long, value_name = "ID")]
pub provider: Option<String>,
/// Force a model (aichat --model)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Use one of the generated copilot roles (am-copilot, am-operator,
/// am-dev, am-do, am-analyst, am-orchestrator — issue v1.1.0 F3)
#[arg(long, value_name = "ROLE")]
pub role: Option<String>,
/// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is
/// handled by clap directly).
#[arg(skip)]
pub yes: bool,
/// REPL-only: --dry-run given on the REPL line.
#[arg(skip)]
pub dry_run: bool,
}
/// Arguments of am serve (issue #80).
#[derive(Args, Debug, Clone, Default)]
pub struct ServeArgs {
+244
View File
@@ -0,0 +1,244 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
//! context files (default: the current directory). Exec mode generates the
//! shell command through aichat in dry-run, classifies it, applies the
//! safety policy (settings.shell_ai, dry-run by default) and only executes
//! after confirmation when required.
use crate::app::App;
use crate::cli::AiArgs;
use crate::commands::{require_agent, resolve_exec};
use crate::events::{Event, EventKind};
use crate::shell_ai::{Decision, SafetyMode};
use anyhow::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
// Onboarding hint (v1.1.0 F1) — non-blocking: the AI commands propose
// `am setup` when no provider is configured, then continue anyway.
if crate::setup::needs_setup(app) {
app.log.info(crate::i18n::tr_in(
app.lang(),
"am n'est pas configuré — lancez am setup (provider + token)",
));
}
// F2 (v1.1.0): aichat must be INSTALLED (not only in the catalog) to
// power am ai. Offer the installation when missing.
if !aichat_installed(app) {
let ask_install = !app.cli.yes
&& app.confirm(crate::i18n::tr_in(
app.lang(),
"aichat est manquant. Installer ?",
))?;
if app.cli.yes || ask_install {
app.log.info(crate::i18n::tr_in(app.lang(), "Installation d'aichat…"));
crate::commands::install_cmd::run(app, "aichat", None, false, None, None, false)?;
} else {
bail!(crate::i18n::tr_in(
app.lang(),
"installez aichat: am install aichat — puis réessayez"
));
}
}
let prompt = args.prompt.join(" ");
// Context files: explicit --files wins, else the current directory
// (issue #96: `aichat -f . "<prompt>"`).
let files: Vec<String> = if args.files.is_empty() {
vec![".".to_string()]
} else {
args.files
.iter()
.map(|p| p.display().to_string())
.collect()
};
// REPL per-line flags combine with the CLI globals.
let yes = app.cli.yes || args.yes;
let dry_run = app.cli.dry_run || args.dry_run;
let agent = require_agent(app, "aichat")?;
if !args.exec {
return chat_mode(app, agent, &prompt, &files, args, dry_run);
}
exec_mode(app, &prompt, &files, args, yes, dry_run)
}
/// True when the aichat binary is available (installed or on PATH).
/// Uses a direct PATH lookup — the probe cache would mask a shim added
/// mid-test and is meant for agent inventories, not runtime checks.
fn aichat_installed(app: &App) -> bool {
app.state.get("aichat").ok().flatten().is_some()
|| which::which("aichat").is_ok()
}
/// Validate the --role flag: the file must exist among the generated
/// am-* roles (v1.1.0 F3). Returns the aichat --agent arguments.
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
let Some(role) = role else {
return Ok(Vec::new());
};
let dir = crate::roles::aichat_agents_dir();
let path = dir.join(format!("{role}.md"));
if !path.exists() {
bail!(crate::i18n::tr_in(
app.lang(),
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
).replace("{}", role));
}
Ok(vec!["--agent".to_string(), role.to_string()])
}
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
/// the provider/model environment applied when requested.
fn chat_mode(
app: &App,
agent: &crate::config::AgentDef,
prompt: &str,
files: &[String],
args: &AiArgs,
dry_run: bool,
) -> Result<i32> {
let mut extra: Vec<String> = Vec::new();
extra.extend(role_args(app, args.role.as_deref())?);
for f in files {
extra.push("-f".to_string());
extra.push(f.clone());
}
extra.push(prompt.to_string());
let (p_args, p_env) =
crate::shell_ai::provider_env(app, args.provider.as_deref(), args.model.as_deref())?;
extra.extend(p_args);
if dry_run {
app.log.dry(&format!(
"would run aichat {} (conversationnel)",
extra.join(" ")
));
return Ok(0);
}
let exec = resolve_exec(app, agent, &extra, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {}",
exec.program,
exec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec!["mode=chat".to_string(), "executed=true".to_string()]),
);
Ok(status.code().unwrap_or(1))
}
/// Exec mode: generate the command (aichat --dry-run), classify it, apply
/// the safety policy, then execute through the shell when allowed.
fn exec_mode(
app: &App,
prompt: &str,
files: &[String],
args: &AiArgs,
yes: bool,
dry_run: bool,
) -> Result<i32> {
if dry_run {
app.log.dry(&format!(
"would generate & classify via aichat (exec) — commande non exécutée"
));
}
let role = role_args(app, args.role.as_deref())?;
let cmd = crate::shell_ai::generate(
app,
prompt,
files,
args.provider.as_deref(),
args.model.as_deref(),
&role,
)?;
let settings = app
.config
.settings
.shell_ai
.clone()
.unwrap_or_default();
let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns);
let certainty = crate::shell_ai::estimate_certainty(&cmd, risk);
let mode = settings.safety_mode();
// Show the generated command and its classification (issue #97 UX).
println!("🔒 Commande générée : {}", cmd);
println!(" Risk : {}", risk.as_str());
println!(" Certainty: {}%", certainty);
println!(
" Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)",
mode.as_str()
);
let decision = crate::shell_ai::decide(mode, risk, dry_run, yes);
let lang = app.lang();
let mut executed = false;
match decision {
Decision::Abort => {
let msg = if dry_run {
crate::i18n::tr_in(lang, "dry-run : commande non exécutée")
} else {
crate::i18n::tr_in(
lang,
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes",
)
};
app.log.info(msg);
}
Decision::Ask => {
let ok = app.confirm(crate::i18n::tr_in(lang, "Exécuter ?"))?;
if ok {
executed = true;
} else {
app.log.info(crate::i18n::tr_in(lang, "annulé"));
}
}
Decision::Execute => {
executed = true;
}
}
let code = if executed {
app.log.info(&format!("exécution: {}", cmd));
let code = crate::shell_ai::execute(app, &cmd)?;
code
} else {
0
};
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec![
"mode=exec".to_string(),
format!("risk={}", risk.as_str()),
format!("certainty={certainty}"),
format!("executed={executed}"),
]),
);
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_dry_run_is_the_default_policy() {
// The embedded config does not set shell_ai -> dry-run default.
let s = crate::config::ShellAiSettings::default();
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
}
+9
View File
@@ -1,6 +1,7 @@
//! Command implementations and dispatch.
pub mod agents_cmd;
pub mod ai_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
@@ -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::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
Command::Ai(args) => ai_cmd::run(app, args),
Command::Setup { roles } => {
if *roles {
crate::setup::run_roles(app)
} else {
crate::setup::run(app)
}
}
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
+1
View File
@@ -655,6 +655,7 @@ mod tests {
install: None,
dependencies: vec![],
run: Some("demo".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
+1
View File
@@ -190,6 +190,7 @@ mod tests {
install: None,
dependencies: vec![],
run: Some(name.to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
+47
View File
@@ -103,6 +103,34 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("--json", "sortie machine-readable")],
example: "suggest un agent pour du Python",
},
TipEntry {
usage: "ask <demande>",
about: "langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (issue #78)",
options: &[("--yes", "exécute la commande sans confirmation")],
example: "ask installe claude et lance-le",
},
TipEntry {
usage: "ai <demande> [--exec] [--files <path>] [--role <rôle>]",
about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut",
options: &[
("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"),
("-f, --files", "fichiers/dossiers en contexte (défaut: dossier courant)"),
("--role", "rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
("--provider", "provider du registre (clé du keyring + modèle)"),
("--model", "forcer un modèle"),
("--yes", "exécute sans confirmation"),
],
example: "ai --exec \"compresse les fichiers JSON en un zip\"",
},
TipEntry {
usage: "setup [--roles]",
about: "wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat, rôles copilot am-* (v1.1.0)",
options: &[
("--roles", "régénérer uniquement les rôles copilot aichat"),
("--yes", "non-interactif (valeurs actuelles)"),
],
example: "setup",
},
TipEntry {
usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
@@ -119,6 +147,15 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml",
},
TipEntry {
usage: "registry publish <catalogue.yaml> --source <url>",
about: "registre communautaire : publie, cherche ou installe un catalogue (manifeste + checksum sha256 vérifié)",
options: &[
("search <mot>", "cherche dans le registre"),
("install <url>", "installe après vérification du checksum et décision de confiance"),
],
example: "registry search agents python",
},
TipEntry {
usage: "audit",
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
@@ -251,6 +288,16 @@ pub static SECTIONS: &[TipSection] = &[
],
example: "web --port 9090",
},
TipEntry {
usage: "serve --token <tok>",
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask) (issue #80)",
options: &[
("--port <p>", "port d'écoute (défaut 8080)"),
("--host <h>", "adresse d'écoute (défaut 127.0.0.1)"),
("--rate-limit <n>", "requêtes par IP et par minute (défaut 120)"),
],
example: "serve --token mon-token --port 9000",
},
TipEntry {
usage: "sync",
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
+73
View File
@@ -162,6 +162,10 @@ pub struct Settings {
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
/// am ai security settings (issue #97): default safety policy and
/// extra risky command patterns for the Shell AI command.
#[serde(default)]
pub shell_ai: Option<ShellAiSettings>,
}
/// Anonymous opt-in telemetry (issue #76): aggregated counters only — never
@@ -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).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -398,6 +430,13 @@ pub struct AgentDef {
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
#[serde(default)]
pub run: Option<String>,
/// Binary name probed on the PATH for external detection. When unset,
/// the first token of `run` is used — which is wrong for interpreter
/// launchers ("npx pkg", "python -m pkg"): the interpreter itself would
/// be matched, reporting the agent as present whenever node/python is
/// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai").
#[serde(default)]
pub detect: Option<String>,
/// Default arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
@@ -516,6 +555,12 @@ impl AgentDef {
})
}
/// Binary probed on the PATH for external detection: the explicit
/// `detect` name when set, otherwise the first token of `run`.
pub fn detect_token(&self) -> Option<String> {
self.detect.clone().or_else(|| self.first_token())
}
/// The run command split into tokens (empty when unset).
pub fn run_tokens(&self) -> Vec<String> {
self.run
@@ -956,6 +1001,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.default_provider.is_some() {
s.default_provider = o.default_provider;
}
if o.shell_ai.is_some() {
s.shell_ai = o.shell_ai;
}
// Providers merge key by key (issue #88): an overlay adds or replaces
// one provider without wiping the others. An explicit `null` in the
// overlay DELETES the provider (the standard YAML overlay pattern) so a
@@ -1167,6 +1215,31 @@ mod tests {
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
}
#[test]
fn detect_token_prefers_explicit_binary() {
// Interpreter launcher: without `detect`, the interpreter itself
// would be probed (false "external" when node/python is on PATH).
let a: AgentDef = serde_yaml::from_str(
"name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n",
)
.unwrap();
assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai"));
// No `detect`: falls back to the first token of run.
let b: AgentDef =
serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap();
assert_eq!(b.detect_token().as_deref(), Some("python"));
assert_eq!(b.first_token().as_deref(), Some("python"));
// Plain binary command is unchanged.
let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap();
assert_eq!(c.detect_token().as_deref(), Some("claude"));
// The embedded catalog must stay valid with the new field.
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap();
assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai"));
let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap();
assert_eq!(nano.detect_token().as_deref(), Some("nanobot"));
}
#[test]
fn expands_paths() {
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
+4
View File
@@ -52,6 +52,9 @@ pub enum EventKind {
Provider,
/// Sandbox refusal journalized for audit (issue #79).
Sandbox,
/// am ai — Shell AI action: generated, classified and/or executed
/// (issues #96 #97).
ShellAi,
}
impl EventKind {
@@ -79,6 +82,7 @@ impl EventKind {
EventKind::Lab => "lab",
EventKind::Provider => "provider",
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" },
],
},
HelpSpec {
name: "setup",
category: "Commands",
usage: "setup {flags}",
about: "Wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat et génération des rôles copilot am-* (épique v1.1.0). Ré-exécutable à tout moment (mode revoir).",
search_terms: &["wizard", "onboarding", "provider", "token", "setup", "configurer", "premier"],
flags: &[
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Mode non-interactif : garde les valeurs actuelles (provider/modèle), installe aichat, régénère les rôles" },
HelpFlag { short: "", long: "--roles", value: "", desc: "Régénérer uniquement les rôles copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Premier lancement (proposé aussi par am ai / le banner REPL)", code: "am setup" },
HelpExample { desc: "Régénérer les rôles après une mise à jour", code: "am setup --roles" },
HelpExample { desc: "Non-interactif (scripts)", code: "am setup --yes" },
],
},
HelpSpec {
name: "ask",
category: "Commands",
@@ -876,6 +895,33 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
],
},
HelpSpec {
name: "ai",
category: "Commands",
usage: "ai {flags} <prompt...>",
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
flags: &[
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" },
HelpFlag { short: "", long: "--role", value: "ROLE", desc: "Rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator — générés par am setup)" },
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" },
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "prompt", typ: "string", desc: "La demande en langage naturel, ex: «liste les fichiers JSON du dossier courant»" },
],
io: None,
examples: &[
HelpExample { desc: "Mode conversationnel (défaut).", code: "ai liste tous les fichiers JSON et extrait les clés uniques" },
HelpExample { desc: "Générer la commande sans l'exécuter (dry-run par défaut).", code: "ai --exec \"compresse les fichiers JSON en un zip\"" },
HelpExample { desc: "Exécuter après confirmation explicite.", code: "ai --exec \"supprime les fichiers .tmp\" --yes" },
HelpExample { desc: "Passer un dossier en contexte.", code: "ai --files ./data \"résume les données de ces fichiers\"" },
],
},
HelpSpec {
name: "registry",
category: "Commands",
+26
View File
@@ -163,6 +163,23 @@ 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»"),
("exécuter ces commandes ?", "run these commands?"),
("annulé", "cancelled"),
("Exécuter ?", "Execute?"),
("dry-run : commande non exécutée", "dry-run: command not executed"),
("politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes", "safety policy (dry-run by default): command not executed — use --yes"),
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"),
("usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai <request> — e.g. ai --exec \"compress the JSON files\""),
("am n'est pas configuré — lancez am setup (provider + token)", "am is not configured — run am setup (provider + token)"),
("Configuration d'agent-manager (am setup)", "agent-manager configuration (am setup)"),
("Le token est stocké dans le trousseau OS — jamais en clair.", "The token is stored in the OS keyring — never in clear."),
("Installer le moteur IA (aichat) ?", "Install the AI engine (aichat)?"),
("Installation d'aichat…", "Installing aichat…"),
("aichat est manquant. Installer ?", "aichat is missing. Install it?"),
("installez aichat: am install aichat — puis réessayez", "install aichat: am install aichat — then retry"),
("Rôles copilot am-* générés", "am-* copilot roles generated"),
("Config aichat créée (provider + modèle)", "aichat config created (provider + model)"),
("am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"", "am is configured — try: am ai \"...\" or am ai --exec \"...\""),
("rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)", "unknown role '{}' — generate the roles with: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
("installation refusée — source non fiable", "installation refused — untrusted source"),
@@ -430,6 +447,15 @@ pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
}
}
/// Format a message for a GIVEN locale (deterministic — follows the app's
/// resolved language instead of the process-global locale).
pub fn tr_fmt_in<'a>(lang: Lang, key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
match lang {
Lang::Fr => fmt_args(key, args),
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
}
}
/// Replace `{}` placeholders (positional) with the Display of each argument.
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
let mut out = String::with_capacity(template.len() + 16);
+3
View File
@@ -14,6 +14,9 @@
pub mod app;
pub mod agent_config;
pub mod ask;
pub mod roles;
pub mod setup;
pub mod shell_ai;
pub mod automation;
pub mod backup;
pub mod catalog;
+33 -5
View File
@@ -79,6 +79,11 @@ struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)]
path_hash: u64,
/// FNV-1a hash of the catalog's detect tokens. When the catalog changes
/// which binaries it probes (new agent, `detect` added), the token
/// index must be rebuilt even if the PATH is unchanged.
#[serde(default)]
token_hash: u64,
/// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)]
tokens: BTreeMap<String, Option<String>>,
@@ -98,6 +103,26 @@ fn path_hash() -> u64 {
h
}
/// Hash of the sorted catalog detect tokens: cache invalidation for catalog
/// changes (new agent, added `detect` field) without re-scanning the PATH.
fn catalog_token_hash(app: &App) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut tokens: Vec<String> = app
.catalog
.agents()
.iter()
.filter_map(|a| a.detect_token())
.collect();
tokens.sort();
for t in tokens {
for b in t.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Build an index of every executable on the PATH in a single pass.
/// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> {
@@ -163,13 +188,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
.unwrap_or_default();
let mut dirty = false;
// Resolve the run-command tokens of the catalog (once per PATH change).
// Resolve the detect tokens of the catalog (once per PATH change or
// catalog change).
let current_hash = path_hash();
if cache.path_hash != current_hash {
let current_tokens = catalog_token_hash(app);
if cache.path_hash != current_hash || cache.token_hash != current_tokens {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
@@ -180,6 +207,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
}
}
cache.path_hash = current_hash;
cache.token_hash = current_tokens;
dirty = true;
}
@@ -195,7 +223,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
@@ -211,7 +239,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue;
}
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if let Some(Some(path)) = cache.tokens.get(&token) {
+82 -3
View File
@@ -113,6 +113,9 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("registry", "publish, search and install agent catalogs (Gitea)"),
("providers", "manage the LLM provider registry (base URLs, models, default)"),
("suggest", "recommend an agent for a request"),
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"),
("setup", "onboarding wizard: provider, token, default model, aichat, copilot roles"),
("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"),
@@ -238,7 +241,7 @@ impl AmCompleter {
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "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",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
@@ -798,7 +801,7 @@ pub fn banner_box(
.to_string(),
));
rows.push(inner(
" models models · models --prune · catalog · suggest · audit".to_string(),
" models models · models --prune · catalog · suggest · ask · ai · setup · audit".to_string(),
));
rows.push(inner(
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
@@ -1108,6 +1111,14 @@ pub fn run(app: &App) -> Result<i32> {
app.log.verbose(&format!("history migration skipped: {e:#}"));
}
print!("{}", banner(app, &session, &sid));
// Onboarding tip (v1.1.0 F1): one discreet line when no provider is
// configured yet.
if crate::setup::needs_setup(app) {
app.log.info(crate::i18n::tr_in(
app.lang(),
"am n'est pas configuré — lancez am setup (provider + token)",
));
}
let result = match run_with_editor(app, &mut session, &sid) {
Ok(code) => Ok(code),
Err(e) => {
@@ -1602,6 +1613,69 @@ fn handle_line(
yes: flag("--yes"),
}
},
"ai" => {
let mut exec = false;
let mut yes = false;
let mut dry_run = false;
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut provider: Option<String> = None;
let mut model: Option<String> = None;
let mut role: Option<String> = None;
let mut prompt: Vec<String> = Vec::new();
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--exec" | "-e" => exec = true,
"--yes" | "-y" => yes = true,
"--dry-run" => dry_run = true,
"--files" | "-f" => {
i += 1;
if i < rest.len() {
files.push(std::path::PathBuf::from(&rest[i]));
}
}
"--provider" => {
i += 1;
if i < rest.len() {
provider = Some(rest[i].clone());
}
}
"--model" => {
i += 1;
if i < rest.len() {
model = Some(rest[i].clone());
}
}
"--role" => {
i += 1;
if i < rest.len() {
role = Some(rest[i].clone());
}
}
other => prompt.push(other.to_string()),
}
i += 1;
}
if prompt.is_empty() {
app.log.error(crate::i18n::tr(
"usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"",
));
return Ok(false);
}
Command::Ai(crate::cli::AiArgs {
prompt,
exec,
files,
provider,
model,
role,
yes,
dry_run,
})
},
"setup" => Command::Setup {
roles: flag("--roles"),
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
@@ -2014,13 +2088,18 @@ pub fn is_am_command(word: &str) -> bool {
| "tip"
| "models"
| "catalog"
| "registry"
| "providers"
| "suggest"
| "ask"
| "ai"
| "setup"
| "audit"
| "service"
| "schedule"
| "monitor"
| "web"
| "serve"
| "sync"
| "migrate"
| "playbook"
@@ -2676,7 +2755,7 @@ mod tests {
for cmd in [
"list", "status", "search", "info", "install", "uninstall", "update", "start",
"stop", "restart", "run", "doctor", "config", "completion", "self-update",
"self-uninstall", "export", "import",
"self-uninstall", "export", "import", "ask", "serve", "registry",
] {
assert!(is_am_command(cmd), "{cmd}");
}
+275
View File
@@ -0,0 +1,275 @@
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
//!
//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or
//! `%APPDATA%\aichat\agents\` on Windows) that turn `am ai` into a
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
//! by default, English supported) and instructs the model to use the
//! stable `--json` contracts of `am` to inspect the environment.
//!
//! The aichat config file is only created when it does not exist yet —
//! an existing configuration is never touched (respect of the user setup).
use crate::app::App;
use anyhow::Result;
use std::path::{Path, PathBuf};
/// The six copilot roles shipped by `am setup` / `am setup --roles`.
pub const ROLE_NAMES: &[&str] = &[
"am-copilot",
"am-operator",
"am-dev",
"am-do",
"am-analyst",
"am-orchestrator",
];
/// Directory where aichat stores its agent definitions. Candidates are
/// probed in order: an existing aichat config wins, otherwise the platform
/// default (XDG on unix, %APPDATA% on Windows).
pub fn aichat_agents_dir() -> PathBuf {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(xdg).join("aichat").join("agents"));
}
if let Some(home) = crate::config::home_dir() {
candidates.push(home.join(".config").join("aichat").join("agents"));
}
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.push(PathBuf::from(appdata).join("aichat").join("agents"));
}
}
for c in &candidates {
if c.exists() {
return c.clone();
}
}
// No existing aichat config: platform default.
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
return PathBuf::from(appdata).join("aichat").join("agents");
}
}
crate::config::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("aichat")
.join("agents")
}
/// Path of the aichat configuration file (created only if absent).
pub fn aichat_config_path() -> PathBuf {
aichat_agents_dir().parent().unwrap().join("config.yaml")
}
/// The list of `am` commands, embedded in the copilot prompt so the model
/// always knows what the CLI can do (kept in sync with `am help commands`).
pub fn command_list() -> String {
let mut out = String::from(
"list, status, sessions, stats, top, report, projects, timeline, log, logs, history, ",
);
out.push_str(
"init, alias, secret, open, watch, search, info, install, uninstall, update, start, stop, ",
);
out.push_str(
"restart, run, doctor, config, completion, self-update, self-uninstall, export, import, ",
);
out.push_str(
"shell, theme, lang, tip, dashboard, favorite, unfavorite, note, tag, untag, tags, ",
);
out.push_str(
"profile, man, models, catalog, providers, suggest, ask, ai, registry, audit, service, ",
);
out.push_str("schedule, monitor, web, serve, sync, migrate, playbook, lab, plugins, setup");
out
}
fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
let mut out = String::from("---\n");
out.push_str(&format!("name: {name}\n"));
out.push_str(&format!("description: {description}\n"));
if !tools.is_empty() {
out.push_str(&format!("tools: [{}]\n", tools.join(", ")));
}
out.push_str("---\n");
out
}
/// The bilingual instruction footer shared by every role.
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
/// Build the markdown content of one role.
pub fn role_content(name: &str) -> Option<String> {
let (desc, tools, body) = match name {
"am-copilot" => (
"Copilot de agent-manager : guide, catalogue, dépannage",
&[][..],
format!(
"Tu es le copilot de **agent-manager (am)**, le gestionnaire d'agents IA locaux.\n\
Tu aides l'utilisateur à exploiter am : catalogue, agents, installation, REPL.\n\
\n\
Commandes am disponibles : {}\n\
\n\
Règles :\n\
- Privilégie les sorties --json pour inspecter l'environnement : am list --json, am status --json, am info <agent>, am search <mot>.\n\
- Pour comprendre l'état : am doctor, am stats --json, am log --kind error.\n\
- Propose TOUJOURS la commande exacte à taper, dans un bloc de code.\n\
- Si une commande est incertaine, dis-le et propose am help <cmd>.",
command_list()
),
),
"am-operator" => (
"Opérateur système sécurisé : commandes shell, fichiers, processus",
&["shell"][..],
"Tu es un opérateur système SÉCURISÉ. Tu transformes les demandes en commandes shell précises.\n\
\n\
Règles de sécurité (non négociables) :\n\
- N'exécute jamais directement : propose la commande, laisse l'utilisateur ou am ai --exec l'exécuter après confirmation.\n\
- Signale clairement les commandes RISKY (rm -rf, dd, mkfs, chmod -R 777, git push --force...) et propose une alternative plus sûre quand elle existe.\n\
- Adapte-toi à l'OS détecté (Windows/cmd/powershell vs Linux/macOS/bash).\n\
- Pour lister/trier/compresser des fichiers, propose la commande la plus simple possible.".to_string(),
),
"am-dev" => (
"Assistant développeur : code, git, tests, revue",
&[][..],
"Tu es un assistant développeur qui travaille AVEC am (agent-manager).\n\
\n\
Tu aides sur :\n\
- Refactoring, debugging, écriture de tests, revue de code (diff).\n\
- Git : am ne gère pas git directement — propose les commandes git exactes.\n\
- Suivi de projets : am projects --json, am timeline --json pour le contexte d'activité.\n\
- Choix d'agent : am search <tâche> pour recommander un coding agent du catalogue, puis am install / am run.\n\
\n\
Réponds avec du code concret et des commandes exactes.".to_string(),
),
"am-do" => (
"Exécution pure : génère la commande brute pour am ai --exec",
&["shell"][..],
"Tu es le moteur d'exécution de am ai --exec.\n\
\n\
Réponds UNIQUEMENT par la commande shell exacte à exécuter, sur une seule ligne si possible.\n\
- Aucune explication avant/après : seulement la commande.\n\
- Si la demande est dangereuse, réponds par la commande la plus conservatrice qui satisfait la demande.\n\
- Adapte-toi à l'OS détecté.".to_string(),
),
"am-analyst" => (
"Analyste : coûts, logs, statistiques, santé du parc d'agents",
&[][..],
"Tu es l'analyste de la flotte d'agents gérée par am.\n\
\n\
Sources de données (toujours en --json) :\n\
- Coûts : am stats --costs --json, am top --json\n\
- Logs & erreurs : am log --kind error --limit 50 --json, am logs <agent> --lines 50\n\
- Activité : am timeline --json, am sessions --json, am report --period week\n\
- Santé : am doctor --json, am status --json\n\
\n\
Produis des synthèses courtes avec les chiffres clés, et propose des actions concrètes.".to_string(),
),
"am-orchestrator" => (
"Orchestrateur : groupes, lab, benchmarks, automatisation",
&[][..],
"Tu es l'orchestrateur des agents am.\n\
\n\
Tu aides à :\n\
- Lancer des groupes : am start group:dev, am start group:dev --parallel\n\
- Benchmarker : am lab --agents a,b --task <fichier> --json\n\
- Automatiser : am schedule add <cmd...>, am service install <agent>, am playbook <fichier>\n\
- Planifier des runs et interpréter les résultats (am lab --json).\n\
\n\
Propose toujours des commandes exactes et des benchmarks comparables.".to_string(),
),
_ => return None,
};
let content = format!(
"{}{}{}",
frontmatter(name, desc, tools),
body,
LANG_FOOTER
);
Some(content)
}
/// Write every am-* role into the aichat agents directory (creates the
/// directory when missing). Existing files with the same name are
/// overwritten — they are generated artifacts of am.
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
let dir = aichat_agents_dir();
std::fs::create_dir_all(&dir)?;
let mut written = Vec::new();
for name in ROLE_NAMES {
let content = role_content(name).expect("known role");
let path = dir.join(format!("{name}.md"));
std::fs::write(&path, content)?;
written.push(path);
}
Ok(written)
}
/// Create the aichat config file (provider + default model) ONLY when it
/// does not exist. An existing configuration is left untouched.
pub fn ensure_config_file(app: &App) -> Result<bool> {
let path = aichat_config_path();
if path.exists() {
return Ok(false);
}
let provider = crate::providers::default_name(&app.config);
let model = provider.and_then(|p| crate::providers::get(&app.config, p))
.and_then(|def| def.default_model.clone());
let mut body = String::from("# generated by `am setup` — first run only\n");
if let Some(p) = provider {
body.push_str(&format!("model_provider: {p}\n"));
}
if let Some(m) = model {
body.push_str(&format!("model: {m}\n"));
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, body)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_all_six_roles_with_frontmatter() {
for name in ROLE_NAMES {
let content = role_content(name).expect("role");
assert!(content.starts_with("---\n"), "{name}");
assert!(content.contains(&format!("name: {name}")), "{name}");
assert!(content.contains("description:"), "{name}");
// Bilingual footer present.
assert!(content.contains("Réponds en français"), "{name}");
}
}
#[test]
fn shell_tools_only_on_execution_roles() {
let operator = role_content("am-operator").unwrap();
assert!(operator.contains("tools: [shell]"));
let copilot = role_content("am-copilot").unwrap();
assert!(!copilot.contains("tools:"), "copilot must not have tools");
}
#[test]
fn unknown_role_returns_none() {
assert!(role_content("am-inconnu").is_none());
}
#[test]
fn copilot_prompt_embeds_the_command_list() {
let copilot = role_content("am-copilot").unwrap();
assert!(copilot.contains("am list --json"));
assert!(copilot.contains("setup"), "command list must include setup");
}
#[test]
fn agents_dir_prefers_existing_aichat_config() {
// With a home dir, the unix candidate is ~/.config/aichat/agents.
let dir = aichat_agents_dir();
assert!(dir.ends_with("agents"), "{dir:?}");
}
}
+389
View File
@@ -0,0 +1,389 @@
//! am setup — the onboarding wizard (épique v1.1.0, F1).
//!
//! Guides the user through: provider choice, masked API-token entry (OS
//! keyring, never in clear), default model selection, a non-blocking API
//! ping, the optional aichat installation (F2) and the generation of the
//! am-* copilot roles for aichat (F3). Re-runnable at any time (`am setup`
//! = "review" mode); triggered on first run by the AI commands and the
//! REPL banner when no provider is configured.
use crate::app::App;
use crate::cli::ProvidersCmd;
use crate::commands::{config_cmd, install_cmd, providers_cmd};
use crate::secrets::SecretStore;
use anyhow::{bail, Result};
use std::io::Write;
/// Known providers offered by the wizard (from the embedded registry plus
/// the local Ollama runtime and a custom endpoint).
const CHOICES: &[&str] = &["anthropic", "openai", "deepseek", "google", "ollama", "custom"];
/// True when the user has not configured a usable default provider yet:
/// no `settings.default_provider`, or the default provider has no API
/// token in the keyring (except local Ollama, which needs none).
pub fn needs_setup(app: &App) -> bool {
let Some(def) = crate::providers::default_name(&app.config) else {
return true;
};
if def == "ollama" {
return false;
}
let store = crate::secrets::store();
store
.get(&crate::secrets::key_for_provider_token(def))
.ok()
.flatten()
.is_none()
}
fn read_line() -> Result<String> {
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
if app.cli.yes {
return Ok(true);
}
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
eprint!("{question} {suffix} ");
std::io::stderr().flush()?;
let answer = read_line()?.to_lowercase();
Ok(match answer.as_str() {
"" => default_yes,
"y" | "yes" | "o" | "oui" | "true" => true,
_ => false,
})
}
/// Pick a provider from the list (or the current default when --yes).
fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
let current = crate::providers::default_name(&app.config);
println!("Provider disponible :");
for (i, p) in CHOICES.iter().enumerate() {
let star = if Some(*p) == current { " (actuel)" } else { "" };
println!(" {}) {}{star}", i + 1, p);
}
if app.cli.yes {
let name = current.unwrap_or("anthropic").to_string();
return Ok((name, None));
}
loop {
print!("Choisissez (1-{}) : ", CHOICES.len());
std::io::stdout().flush()?;
let raw = read_line()?;
if let Ok(n) = raw.parse::<usize>() {
if (1..=CHOICES.len()).contains(&n) {
let name = CHOICES[n - 1];
if name == "custom" {
print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
std::io::stdout().flush()?;
let url = read_line()?;
if url.is_empty() {
continue;
}
return Ok(("custom".to_string(), Some(url)));
}
return Ok((name.to_string(), None));
}
}
if !raw.is_empty() {
return Ok((raw, None));
}
}
}
/// The declared models of a provider (embedded registry), or an empty list
/// for local/custom providers.
fn declared_models(app: &App, name: &str) -> Vec<String> {
crate::providers::get(&app.config, name)
.map(|d| d.models.clone())
.unwrap_or_default()
}
/// Ask for the default model: numbered list of the declared models plus a
/// free entry. `--yes` keeps the provider's current default.
fn pick_model(app: &App, name: &str) -> Result<String> {
let models = declared_models(app, name);
let current = crate::providers::get(&app.config, name)
.and_then(|d| d.default_model.clone())
.or_else(|| models.first().cloned());
if !models.is_empty() {
println!("Modèles disponibles pour {name} :");
for (i, m) in models.iter().enumerate() {
let star = if Some(m) == current.as_ref() { " (défaut)" } else { "" };
println!(" {}) {m}{star}", i + 1);
}
println!(" {}) autre modèle…", models.len() + 1);
} else {
println!("Aucun modèle déclaré pour {name} — saisissez le modèle par défaut.");
}
if app.cli.yes {
return Ok(current.unwrap_or_else(|| "gpt-4o".to_string()));
}
loop {
print!("Modèle par défaut : ");
std::io::stdout().flush()?;
let raw = read_line()?;
if let Ok(n) = raw.parse::<usize>() {
if n >= 1 && n <= models.len() {
return Ok(models[n - 1].clone());
}
if n == models.len() + 1 {
print!("Nom du modèle : ");
std::io::stdout().flush()?;
let custom = read_line()?;
if !custom.is_empty() {
return Ok(custom);
}
continue;
}
}
if !raw.is_empty() {
return Ok(raw);
}
}
}
/// Non-blocking API ping: any HTTP answer (200, 401…) proves the endpoint
/// is reachable and the token is transmitted. Network failures are logged
/// as warnings only — the wizard never blocks on this.
fn ping_provider(name: &str, base_url: &str, token: Option<&str>) {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let result = match token {
Some(t) => ureq::get(&url).set("Authorization", &format!("Bearer {t}")).call(),
None => ureq::get(&url).call(),
};
match result {
Ok(_) | Err(ureq::Error::Status(_, _)) => {
// Reachable (200 or an auth-status): the endpoint answered.
}
Err(e) => {
eprintln!("⚠ ping du provider échoué (non bloquant) : {e}");
}
}
let _ = name;
}
/// Main wizard. `--yes` runs it non-interactively with the current
/// defaults (install aichat + roles included).
pub fn run(app: &App) -> Result<i32> {
let lang = app.lang();
if !app.cli.yes {
println!("⚙ {}", crate::i18n::tr_in(lang, "Configuration d'agent-manager (am setup)"));
println!("{}", crate::i18n::tr_in(lang, "Le token est stocké dans le trousseau OS — jamais en clair."));
}
// 1) Provider.
let (provider, custom_url) = pick_provider(app)?;
let base_url = if provider == "custom" {
custom_url.clone().unwrap_or_default()
} else {
crate::providers::get(&app.config, &provider)
.map(|d| d.base_url.clone())
.unwrap_or_default()
};
if provider == "custom" {
let cmd = ProvidersCmd::Add {
name: "custom".to_string(),
base_url: custom_url.clone().unwrap_or_default(),
model: None,
models: None,
};
providers_cmd::run(app, Some(&cmd))?;
} else if provider == "ollama" {
// Local runtime: register it (it is not part of the embedded
// registry) so the config stays valid, then keep going tokenless.
let cmd = ProvidersCmd::Add {
name: "ollama".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
model: None,
models: None,
};
providers_cmd::run(app, Some(&cmd))?;
}
// 2) Token (masked) — skipped for local Ollama.
let mut token: Option<String> = None;
if provider != "ollama" {
if app.cli.yes {
let store = crate::secrets::store();
token = store
.get(&crate::secrets::key_for_provider_token(&provider))
.ok()
.flatten();
} else {
let value = rpassword::prompt_password(&format!(
"Token API pour {provider} (invisible) : "
))?;
if !value.is_empty() {
token = Some(value.clone());
let cmd = ProvidersCmd::SetToken {
name: provider.clone(),
value,
};
providers_cmd::run(app, Some(&cmd))?;
}
}
}
// 3) Default model. The registry lives under settings.providers — a
// top-level `providers:` key is rejected by the schema validator, and a
// provider block written to the user config requires base_url too.
let model = pick_model(app, &provider)?;
if provider == "ollama" {
// base_url was already written by the Add above.
config_cmd::persist_setting(app, "settings.providers.ollama.default_model", &model)?;
} else if provider != "custom" {
if let Some(def) = crate::providers::get(&app.config, &provider) {
config_cmd::persist_setting(
app,
&format!("settings.providers.{provider}.base_url"),
&def.base_url,
)?;
config_cmd::persist_setting(
app,
&format!("settings.providers.{provider}.default_model"),
&model,
)?;
}
}
// 4) Default provider.
config_cmd::persist_setting(app, "settings.default_provider", &provider)?;
// 5) Non-blocking ping.
if provider != "ollama" && !base_url.is_empty() {
ping_provider(&provider, &base_url, token.as_deref());
}
// 6) Install aichat (F2) — skipped when already present.
let aichat_present = app
.state
.get("aichat")
.ok()
.flatten()
.is_some()
|| which::which("aichat").is_ok();
if !aichat_present {
if ask(app, crate::i18n::tr_in(lang, "Installer le moteur IA (aichat) ?"), true)? {
println!("{}", crate::i18n::tr_in(lang, "Installation d'aichat…"));
install_cmd::run(app, "aichat", None, false, None, None, false)?;
}
} else {
println!("✓ aichat déjà installé");
}
// 7) Copilot roles (F3) + aichat config (only when absent).
let roles = crate::roles::generate_all(app)?;
let config_created = crate::roles::ensure_config_file(app)?;
println!(
"✓ {} ({} fichiers)",
crate::i18n::tr_in(lang, "Rôles copilot am-* générés"),
roles.len()
);
if config_created {
println!("✓ {}", crate::i18n::tr_in(lang, "Config aichat créée (provider + modèle)"));
}
app.log.success(&crate::i18n::tr_in(
lang,
"am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"",
));
Ok(0)
}
/// `am setup --roles`: (re)generate only the copilot roles.
pub fn run_roles(app: &App) -> Result<i32> {
let roles = crate::roles::generate_all(app)?;
println!("✓ {} fichiers de rôle :", roles.len());
for p in &roles {
println!(" {}", p.display());
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::cli::Cli;
use clap::Parser;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn app_with(settings_yaml: &str) -> App {
app_with_path(settings_yaml).0
}
fn app_with_path(settings_yaml: &str) -> (App, PathBuf) {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"am-setup-test-{}-{id}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{settings_yaml}agents: []\n"
),
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
(App::from_cli(cli).unwrap(), cfg)
}
#[test]
fn needs_setup_true_without_a_configured_provider() {
// The embedded default ships default_provider: anthropic, but no
// keyring token exists in a test process -> setup needed.
let app = app_with("");
assert!(needs_setup(&app));
}
#[test]
fn needs_setup_false_for_local_ollama() {
let app = app_with(
" default_provider: ollama\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n",
);
assert!(!needs_setup(&app));
}
#[test]
fn setup_persists_under_settings_providers() {
// Regression: a top-level `providers:` key is rejected by the
// schema validator ("unknown field `providers`"). The wizard must
// write settings.providers.<name>.default_model.
let (app, cfg) = app_with_path("");
config_cmd::persist_setting(
&app,
"settings.providers.deepseek.base_url",
"https://api.deepseek.com",
)
.unwrap();
config_cmd::persist_setting(
&app,
"settings.providers.deepseek.default_model",
"deepseek-reasoner",
)
.unwrap();
config_cmd::persist_setting(&app, "settings.default_provider", "deepseek").unwrap();
// Reloading the config must succeed (validation passes) and the
// merged registry must reflect the new default model.
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app2 = App::from_cli(cli).expect("config must stay valid after setup writes");
let def = crate::providers::get(&app2.config, "deepseek").unwrap();
assert_eq!(def.default_model.as_deref(), Some("deepseek-reasoner"));
assert_eq!(
crate::providers::default_name(&app2.config).as_deref(),
Some("deepseek")
);
}
}
+465
View File
@@ -0,0 +1,465 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! The Shell AI command turns a natural-language request into a shell
//! action using AIChat (the `aichat` catalog agent) as the generation
//! engine:
//!
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
//! spawned in the foreground, exactly as the user would run it;
//! - exec mode (`--exec`): aichat is asked to generate the command with
//! `--dry-run` (it prints the command and never runs it), then the
//! generated command is classified (safe / risky), a confidence score
//! is estimated, the configured safety policy is applied (dry-run by
//! default), and only then — after confirmation when required — is the
//! command executed through the user's shell.
//!
//! Nothing is ever executed without an explicit confirmation: the default
//! policy is `dry-run`, overridden by `--yes` (execute without asking),
//! the `settings.shell_ai.default_safety` setting (dry-run | confirm |
//! auto) or the explicit `--dry-run` flag (never execute, show only).
use crate::app::App;
use crate::commands::{require_agent, resolve_exec};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::BTreeMap;
use std::process::Command;
/// Safety policies of `am ai --exec` (issue #97).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SafetyMode {
/// Show the generated command only — nothing is executed unless the
/// user passes `--yes`.
DryRun,
/// Execute safe commands directly; ask before risky ones.
Confirm,
/// Execute everything without asking (explicitly enabled by the user).
Auto,
}
impl SafetyMode {
pub fn as_str(&self) -> &'static str {
match self {
SafetyMode::DryRun => "dry-run",
SafetyMode::Confirm => "confirm",
SafetyMode::Auto => "auto",
}
}
}
/// Risk class of a generated shell command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Risk {
Safe,
Risky,
}
impl Risk {
pub fn as_str(&self) -> &'static str {
match self {
Risk::Safe => "safe",
Risk::Risky => "risky",
}
}
}
/// Built-in substrings that mark a command as risky (issue #97). The user
/// can extend this list with `settings.shell_ai.risky_patterns`.
pub const BUILTIN_RISKY_PATTERNS: &[&str] = &[
"rm -rf",
"rm -fr",
"rm -r -f",
"dd if=",
"mkfs.",
"fdisk",
"parted",
"gdisk",
":(){",
"chmod -R 777",
"chmod 777 /",
"chown -R",
"> /dev/sd",
">/dev/sd",
"sudo rm",
"git push --force",
"git push -f",
"drop database",
"drop table",
"truncate table",
"shutdown",
"reboot",
"poweroff",
"kill -9",
"pkill -9",
"killall",
"init 0",
"init 6",
"mv / ",
"rm /",
"format c:",
"del /f /s",
"rd /s",
"cipher /w",
"curl ... | sh",
"curl ... | bash",
"wget ... | sh",
];
/// Classify a generated command against the built-in patterns plus the
/// user-configured ones (`settings.shell_ai.risky_patterns`).
pub fn classify(cmd: &str, extra_patterns: &[String]) -> Risk {
let lower = cmd.to_lowercase();
let hits = BUILTIN_RISKY_PATTERNS
.iter()
.any(|p| lower.contains(&p.to_lowercase()))
|| extra_patterns.iter().any(|p| lower.contains(&p.to_lowercase()));
if hits {
Risk::Risky
} else {
Risk::Safe
}
}
/// A coarse confidence heuristic (0-100) shown to the user. Risky or
/// compound commands are scored lower; simple, read-only commands score
/// higher. It is an estimate, never a guarantee.
pub fn estimate_certainty(cmd: &str, risk: Risk) -> u8 {
let mut score: i32 = 92;
if risk == Risk::Risky {
score -= 25;
}
// Compound commands chain several effects — harder to predict.
for sep in ["&&", "||", ";\n", "\n", " | ", " 2>"] {
if cmd.contains(sep) {
score -= 6;
}
}
// Redirections and pipes move data around.
if cmd.contains('>') || cmd.contains('<') {
score -= 5;
}
if cmd.contains("sudo") {
score -= 8;
}
score.clamp(40, 99) as u8
}
/// Decide what to do with a generated command under the effective policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// Print and abort (dry-run or declined confirmation).
Abort,
/// Ask the user (y/N) before executing.
Ask,
/// Execute directly.
Execute,
}
/// Apply the safety policy: explicit `--dry-run` flag > `--yes` flag >
/// configured default mode (fallback: dry-run).
pub fn decide(
mode: SafetyMode,
risk: Risk,
dry_run_flag: bool,
yes_flag: bool,
) -> Decision {
if dry_run_flag {
return Decision::Abort;
}
if yes_flag {
return Decision::Execute;
}
match mode {
SafetyMode::Auto => Decision::Execute,
SafetyMode::Confirm => {
if risk == Risk::Risky {
Decision::Ask
} else {
Decision::Execute
}
}
SafetyMode::DryRun => Decision::Abort,
}
}
/// Extract the shell command from aichat's `--dry-run` output: strips a
/// fenced code block if present, otherwise uses the trimmed output as-is.
pub fn extract_command(stdout: &str) -> Option<String> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
// Fenced block: ```bash ... ``` (or ```sh, ```powershell, ```cmd ...)
let mut lines = trimmed.lines();
if lines.next().is_some_and(|l| l.trim_start().starts_with("```")) {
let body: Vec<&str> = lines
.take_while(|l| !l.trim().starts_with("```"))
.collect();
let cmd = body.join("\n").trim().to_string();
if !cmd.is_empty() {
return Some(cmd);
}
}
Some(trimmed.to_string())
}
/// Map a provider of the registry to the environment variables AIChat
/// understands, and resolve the model to pass with `--model`. The token is
/// resolved from the OS keyring via the standard `@secret` mechanism and
/// never appears on the command line (issue #89).
pub fn provider_env(
app: &App,
provider: Option<&str>,
model: Option<&str>,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
// Without any flag, aichat keeps its own configuration (its config file
// and API keys) — the registry is only applied on explicit request.
if provider.is_none() && model.is_none() {
return Ok((Vec::new(), BTreeMap::new()));
}
let Some((pname, def, resolved_model)) =
crate::providers::resolve_for(&app.config, None, None, provider, model)
else {
if provider.is_some() {
let known = crate::providers::names(&app.config);
let hint = if known.is_empty() {
"aucun (am providers add <nom> --base-url <url>)".to_string()
} else {
known.join(", ")
};
bail!(crate::i18n::tr_fmt_in(
app.lang(),
"provider '{}' inconnu — providers enregistrés: {}",
&[&provider.unwrap_or(""), &hint]
));
}
return Ok((Vec::new(), BTreeMap::new()));
};
let mut args: Vec<String> = Vec::new();
let mut env: BTreeMap<String, String> = BTreeMap::new();
// Known providers map to AIChat's standard API-key variables; custom
// provider names fall back to the openai-compatible channel.
let key_var = match pname {
"anthropic" => Some("ANTHROPIC_API_KEY"),
"openai" => Some("OPENAI_API_KEY"),
"deepseek" => Some("DEEPSEEK_API_KEY"),
"google" => Some("GOOGLE_API_KEY"),
"groq" => Some("GROQ_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
"xai" | "grok" => Some("XAI_API_KEY"),
"ollama" => None, // local — no key; aichat knows its default endpoint
_ => Some("OPENAI_API_KEY"),
};
if let Some(var) = key_var {
env.insert(var.to_string(), "@secret".to_string());
if !matches!(pname, "openai" | "ollama" | "anthropic" | "deepseek" | "google" | "groq" | "openrouter" | "xai" | "grok") {
// Custom endpoints ride the openai-compatible channel.
env.insert("OPENAI_API_BASE".to_string(), def.base_url.clone());
}
}
if let Some(m) = resolved_model {
args.push("--model".to_string());
args.push(m.to_string());
}
let warnings = crate::secrets::resolve_env_secrets(
&crate::secrets::store(),
"aichat",
Some(pname),
&mut env,
);
for w in warnings {
app.log.warn(&w);
}
Ok((args, env))
}
/// Generate a shell command for `prompt` using aichat in dry-run mode.
/// Returns the generated command (never executed by aichat).
pub fn generate(
app: &App,
prompt: &str,
files: &[String],
provider: Option<&str>,
model: Option<&str>,
role_args: &[String],
) -> Result<String> {
let agent = require_agent(app, "aichat")?;
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
extra_args.extend(role_args.iter().cloned());
for f in files {
extra_args.push("-f".to_string());
extra_args.push(f.clone());
}
extra_args.push(prompt.to_string());
let (p_args, p_env) = provider_env(app, provider, model)?;
extra_args.extend(p_args);
let exec = resolve_exec(app, agent, &extra_args, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {} (dry-run generation)",
exec.program,
exec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let out = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.output()
.with_context(|| format!("failed to run {}", exec.program))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
bail!(crate::i18n::tr_fmt_in(
app.lang(),
"aichat n'a pas généré de commande (exit {}){}",
&[
&out.status.code().unwrap_or(-1).to_string(),
&if err.is_empty() {
String::new()
} else {
format!(": {err}")
},
]
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
extract_command(&stdout).ok_or_else(|| {
anyhow!(crate::i18n::tr_in(
app.lang(),
"aichat n'a retourné aucune commande (dry-run)"
))
})
}
/// Execute a generated command through the user's shell (default_shell >
/// $SHELL > $COMSPEC > cmd). The command is passed as a single argument.
pub fn execute(app: &App, command: &str) -> Result<i32> {
let spec = crate::shell::resolve_default(
app.config.settings.default_shell.as_deref(),
std::env::var_os("SHELL"),
std::env::var_os("COMSPEC"),
);
app.log.verbose(&format!(
"shell-ai: executing via {} {}",
spec.program,
spec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(spec.program);
let mut args = prefix;
args.extend(spec.args.iter().map(|s| s.to_string()));
args.push(command.to_string());
let status = Command::new(&prog)
.args(&args)
.status()
.with_context(|| format!("failed to run {}", spec.program))?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_safe_and_risky() {
assert_eq!(classify("ls -la", &[]), Risk::Safe);
assert_eq!(classify("echo hello", &[]), Risk::Safe);
assert_eq!(classify("find . -name '*.json'", &[]), Risk::Safe);
assert_eq!(classify("rm -rf /tmp/x", &[]), Risk::Risky);
assert_eq!(classify("sudo rm -rf /var/log", &[]), Risk::Risky);
assert_eq!(classify("dd if=/dev/zero of=/dev/sda", &[]), Risk::Risky);
assert_eq!(classify("git push --force origin main", &[]), Risk::Risky);
assert_eq!(classify("drop database prod;", &[]), Risk::Risky);
}
#[test]
fn classifies_extra_user_patterns() {
let extra = vec!["killall node".to_string()];
assert_eq!(classify("killall node", &extra), Risk::Risky);
assert_eq!(classify("killall nodejs", &extra), Risk::Risky);
assert_eq!(classify("node --version", &extra), Risk::Safe);
}
#[test]
fn certainty_stays_in_bounds_and_penalizes_risk() {
let safe = estimate_certainty("ls -la", Risk::Safe);
let risky = estimate_certainty("rm -rf /", Risk::Risky);
assert!(safe > risky, "{safe} should be > {risky}");
assert!((40..=99).contains(&safe));
assert!((40..=99).contains(&risky));
let compound = estimate_certainty("rm -rf /tmp/a && echo ok", Risk::Risky);
assert!(compound < risky || compound == risky);
}
#[test]
fn extract_command_handles_fences_and_plain() {
assert_eq!(
extract_command("```bash\nrm *.tmp\n```"),
Some("rm *.tmp".to_string())
);
assert_eq!(
extract_command("```sh\necho hello\n```\n"),
Some("echo hello".to_string())
);
assert_eq!(
extract_command("rm *.tmp\n"),
Some("rm *.tmp".to_string())
);
assert_eq!(extract_command(" \n "), None);
}
#[test]
fn decision_matrix() {
// dry-run (default): abort, even with --yes overriding to execute.
assert_eq!(
decide(SafetyMode::DryRun, Risk::Risky, false, false),
Decision::Abort
);
assert_eq!(
decide(SafetyMode::DryRun, Risk::Safe, false, false),
Decision::Abort
);
assert_eq!(
decide(SafetyMode::DryRun, Risk::Risky, false, true),
Decision::Execute
);
// explicit --dry-run wins over --yes.
assert_eq!(
decide(SafetyMode::Auto, Risk::Risky, true, true),
Decision::Abort
);
// confirm: ask only for risky.
assert_eq!(
decide(SafetyMode::Confirm, Risk::Safe, false, false),
Decision::Execute
);
assert_eq!(
decide(SafetyMode::Confirm, Risk::Risky, false, false),
Decision::Ask
);
// auto: execute everything.
assert_eq!(
decide(SafetyMode::Auto, Risk::Risky, false, false),
Decision::Execute
);
}
#[test]
fn safety_mode_parsing_falls_back_to_dry_run() {
use crate::config::ShellAiSettings;
let s = ShellAiSettings {
default_safety: Some("confirm".to_string()),
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::Confirm);
let s = ShellAiSettings {
default_safety: Some("n'importe quoi".to_string()),
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
let s = ShellAiSettings {
default_safety: None,
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
}
+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}");
}