Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f29baea5 | ||
|
|
cf53c98d98 | ||
|
|
991c79de50 | ||
|
|
d911ce5d92 | ||
|
|
fa2c42d505 | ||
|
|
e637c066fe | ||
|
|
66a3eebbd6 | ||
|
|
e71cffb3c0 | ||
|
|
7fb65c257d | ||
|
|
068940495a | ||
|
|
d71c3dd34d | ||
|
|
50f515e406 | ||
|
|
313645eea2 | ||
|
|
02517735be | ||
|
|
0f81b7c585 | ||
|
|
0a12c617b2 | ||
|
|
ee490f5eaf | ||
|
|
79e032b563 | ||
|
|
d0f52eb7c0 | ||
|
|
38d38d51e3 | ||
|
|
5bcbc1c81f | ||
|
|
38297c2bf3 | ||
|
|
ac6f2ffaa5 | ||
|
|
2d90c48532 | ||
|
|
92af59a71f | ||
|
|
900f70c249 | ||
|
|
12eb09b01a | ||
|
|
7cab1a0b5c | ||
|
|
6d51ce9504 | ||
|
|
68eb170f57 | ||
|
|
ebc753cc4a | ||
|
|
7312e6ceab | ||
|
|
d38cd7352f | ||
|
|
1948e3bc79 | ||
|
|
4735fc1502 |
@@ -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
|
||||
|
||||
+100
-4
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -205,7 +230,7 @@ settings:
|
||||
|
||||
aliases:
|
||||
cc: claude-code
|
||||
gemini: gemini-cli
|
||||
gemini: antigravity-cli
|
||||
|
||||
groups:
|
||||
dev: [claude-code, aider, codex]
|
||||
@@ -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
|
||||
@@ -461,7 +506,7 @@ Disponibles avant ou après la sous-commande.
|
||||
```yaml
|
||||
aliases:
|
||||
cc: claude-code
|
||||
gemini: gemini-cli
|
||||
gemini: antigravity-cli
|
||||
```
|
||||
|
||||
`am start cc` démarre `claude-code`.
|
||||
@@ -673,6 +718,57 @@ flowchart LR
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Shell AI
|
||||
|
||||
`am ai` (alias `am shell`) est une commande dédiée aux **actions shell via langage naturel**. Elle repose sur un agent léger (par défaut **AIChat**, installé comme n'importe quel autre agent via le catalogue) et réutilise le registre de providers LLM (`providers.rs`) pour choisir le modèle le plus rapide/cheap.
|
||||
|
||||
### Flux d'exécution
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User
|
||||
participant cli as cli.rs
|
||||
participant shell_ai as shell_ai.rs
|
||||
participant catalog as catalog.rs
|
||||
participant providers as providers.rs
|
||||
participant runner as runner.rs
|
||||
participant aichat as aichat (agent)
|
||||
|
||||
User->>cli: am ai "traite les JSON"
|
||||
cli->>shell_ai: parse args (--exec, --files)
|
||||
shell_ai->>catalog: agent "aichat" installé ?
|
||||
catalog-->>shell_ai: Ok / install
|
||||
shell_ai->>providers: provider & modèle par défaut
|
||||
providers-->>shell_ai: config (ollama / cheap cloud)
|
||||
shell_ai->>shell_ai: injecte cwd + fichiers (--files)
|
||||
shell_ai->>runner: exec aichat -f . -e "..."
|
||||
runner->>aichat: lancement processus
|
||||
aichat-->>runner: commande générée / exécutée
|
||||
runner-->>shell_ai: output + exit code
|
||||
shell_ai->>shell_ai: journalise événement shell_ai
|
||||
shell_ai-->>User: résultat ou confirmation
|
||||
```
|
||||
|
||||
### Sécurité
|
||||
|
||||
| Règle | Détail |
|
||||
|---|---|
|
||||
| `--dry-run` par défaut | Aucune commande modifiante n'est exécutée sans confirmation |
|
||||
| Classification risk/certainty | Inspiré d'AI CLI : chaque commande est classée `safe` ou `risky` |
|
||||
| Confirmation utilisateur | Les commandes `risky` demandent une validation explicite |
|
||||
| Mode local possible | Support d'Ollama via `providers.rs` pour ne pas sortir les données |
|
||||
|
||||
### Dépendances
|
||||
|
||||
- `src/shell_ai.rs` : parsing du prompt, gestion des flags, appel à l'agent
|
||||
- `src/providers.rs` : résolution du provider/modèle
|
||||
- `src/runner.rs` : exécution du binaire `aichat`
|
||||
- `src/events.rs` : journalisation `shell_ai` dans le journal JSONL
|
||||
- `config.yaml` : définition de l'agent `aichat` + alias `ai`
|
||||
|
||||
---
|
||||
|
||||
## 📚 Références
|
||||
|
||||
- `src/lib.rs:4` : documentation d'architecture du crate
|
||||
|
||||
Generated
+182
-11
@@ -21,9 +21,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.6.0"
|
||||
version = "1.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -35,15 +36,18 @@ dependencies = [
|
||||
"nu-ansi-term",
|
||||
"ratatui",
|
||||
"reedline",
|
||||
"rpassword",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"shell-words",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"toml",
|
||||
"ureq",
|
||||
"wait-timeout",
|
||||
"which",
|
||||
@@ -1101,7 +1105,10 @@ version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"linux-keyutils",
|
||||
"log",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1138,6 +1145,16 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-keyutils"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1763,6 +1780,27 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -1887,6 +1925,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
@@ -2301,6 +2348,47 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -2693,7 +2781,7 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2702,7 +2790,16 @@ version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2720,14 +2817,31 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2736,48 +2850,105 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.19"
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.6.0"
|
||||
version = "1.1.9"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
@@ -32,9 +32,13 @@ shell-words = "1"
|
||||
tar = "0.4"
|
||||
tempfile = "3"
|
||||
tiny_http = "0.12"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
toml = "0.8"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wait-timeout = "0.2"
|
||||
keyring = "3"
|
||||
keyring = { version = "3", features = ["windows-native", "linux-native"] }
|
||||
rpassword = "7"
|
||||
which = "7"
|
||||
zip = "0.6"
|
||||
crossterm = "0.29"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🚀 agent-manager — vos agents IA, gérés comme des apps
|
||||
|
||||
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
|
||||
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **72 agents connus**)
|
||||
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **78 agents connus**)
|
||||
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
|
||||
Go, Rust…) prise en charge pour vous.
|
||||
|
||||
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
|
||||
|
||||
## ✨ Pourquoi am ?
|
||||
|
||||
- 🗂️ **Catalogue de 72 agents prêts à l'emploi** — descriptions, méthodes
|
||||
- 🗂️ **Catalogue de 78 agents prêts à l'emploi** — descriptions, méthodes
|
||||
d'installation, dépendances et commandes de démarrage déjà configurées.
|
||||
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
|
||||
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
|
||||
@@ -48,6 +48,32 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
|
||||
> 🔄 **Mise à jour** : relancez simplement la même commande.
|
||||
|
||||
### 🚀 Premier lancement (onboarding v1.1.0)
|
||||
|
||||
Au premier lancement (ou dès que vous tapez `am ai` / `am ask` sans
|
||||
provider configuré), `am` vous propose le wizard d'onboarding :
|
||||
|
||||
am setup
|
||||
|
||||
Il vous guide en 3 étapes :
|
||||
1. **Provider** : anthropic, openai, deepseek, google, ollama (local) ou
|
||||
custom (base URL).
|
||||
2. **Token API** : saisie masquée, stockée dans le trousseau OS (keyring)
|
||||
— jamais écrite en clair dans la config.
|
||||
3. **Modèle par défaut** : choisi parmi les modèles déclarés du provider.
|
||||
|
||||
Le wizard installe ensuite **aichat** (le moteur de `am ai`) si vous
|
||||
acceptez, et génère **6 rôles copilot** pour aichat (`am setup --roles`
|
||||
pour les régénérer) :
|
||||
|
||||
| Rôle | Rôle |
|
||||
|---|---|
|
||||
| `am-copilot` — guide & catalogue am | `am-dev` — code, git, tests |
|
||||
| `am-operator` — shell sécurisé | `am-analyst` — coûts, logs, stats |
|
||||
| `am-do` — exécution pure pour `--exec` | `am-orchestrator` — groupes, lab |
|
||||
|
||||
Utilisation : `am ai --role am-operator "compresse les fichiers JSON"`.
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Vos 3 premières commandes
|
||||
@@ -78,11 +104,16 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
| am list --all | tout le catalogue, avec l'état de chacun |
|
||||
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
|
||||
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
|
||||
| am ask "<demande>" | langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (settings.ask), confirmation avant exécution — ex: am ask "installe claude et lance-le" |
|
||||
| am ai "<demande>" [--exec] [--files <path>] [--role <rôle>] | langage naturel → action shell via AIChat (alias: am shell) : conversationnel par défaut ; --exec génère la commande, la classe safe/risky et applique la politique de sécurité (settings.shell_ai, dry-run par défaut — --yes pour exécuter) ; --role = rôle copilot am-* (am setup) |
|
||||
| am setup [--roles] | wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat + rôles copilot (v1.1.0) |
|
||||
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
|
||||
| am status [agent] | état, version, PID, logs |
|
||||
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
|
||||
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
|
||||
| am providers list / add / set-token | registre LLM centralisé : base_url, modèles par provider, clé dans le trousseau (jamais en clair, résolue via @secret) |
|
||||
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
|
||||
| am registry publish/search/install | registre communautaire de catalogues (Gitea) : publication, recherche, installation avec checksum sha256 vérifié et décision de confiance explicite |
|
||||
| am audit | qui a modifié quoi, quand (checksums config + journal) |
|
||||
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
|
||||
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
|
||||
@@ -113,6 +144,48 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
|
||||
| am export / am import | sauvegarde et restaure config + état |
|
||||
|
||||
### 📁 Où et comment agent-manager installe et gère les agents
|
||||
|
||||
`am` ne touche jamais au système : chaque agent est installé dans un **répertoire utilisateur isolé**, et ses exécutables (ou shims) sont centralisés dans un `bin/` commun que `am` met automatiquement sur le `PATH` au lancement.
|
||||
|
||||
| Plateforme | Répertoire de l'agent | Dossier `bin` partagé | Fichier d'état | Configuration utilisateur |
|
||||
|---|---|---|---|---|
|
||||
| Linux | `~/.local/share/agent-manager/agents/<agent>/` | `~/.local/share/agent-manager/bin/` | `~/.local/state/agent-manager/state.json` | `~/.config/agent-manager/config.yaml` |
|
||||
| macOS | `~/Library/Application Support/agent-manager/agents/<agent>/` | `~/Library/Application Support/agent-manager/bin/` | `~/Library/Application Support/agent-manager/state.json` | `~/.config/agent-manager/config.yaml` |
|
||||
| Windows | `%LOCALAPPDATA%\agent-manager\agents\<agent>\` | `%LOCALAPPDATA%\agent-manager\bin\` | `%LOCALAPPDATA%\agent-manager\state.json` | `%APPDATA%\agent-manager\config.yaml` |
|
||||
|
||||
Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AGENT_MANAGER_CONFIG_DIR` peuvent déplacer ces emplacements. Le répertoire d'installation est aussi configurable via `settings.install_dir` et celui des logs via `settings.log_dir`.
|
||||
|
||||
#### Cycle d'installation (`am install <agent>`)
|
||||
|
||||
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (78 agents) avec `~/.config/agent-manager/config.yaml` et un éventuel `./agent-manager.yaml`. Vos définitions remplacent les entrées par défaut du même nom.
|
||||
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place.
|
||||
3. **Choix de la méthode** — chaque agent déclare une méthode principale et des alternatives. `--method <nom>` force le choix ; sinon `am` prend la première disponible. Pour `am update`, c'est la méthode utilisée lors de l'installation initiale qui est reconstruite.
|
||||
4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
|
||||
|
||||
| Méthode | Ce que `am` exécute localement | Où ça atterrit |
|
||||
|---|---|---|
|
||||
| `npm` | `npm install -g --prefix <root> <package>` | binaires dans `<root>/bin/`, liens ou copie dans `bin/` |
|
||||
| `bun` | `BUN_INSTALL=<root> bun install -g <package>` | binaires dans `<root>/bin/`, shims dans `bin/` |
|
||||
| `pip` | `python -m venv <root>/venv` puis `<venv>/bin/pip install --upgrade <package>` | exécutables dans `<root>/venv/bin/` |
|
||||
| `uv` | `uv venv --python 3.13 <root>/venv` puis `uv pip install --python <venv>/bin/python <package>` | exécutables dans `<root>/venv/bin/` |
|
||||
| `cargo` | `cargo install --root <root> <crate>` | binaires dans `<root>/bin/` |
|
||||
| `go` | `GOBIN=<bindir> go install <module>@latest` | binaire directement dans `bin/` |
|
||||
| `curl` | télécharge le script, l'affiche en `--verbose`, puis l'exécute avec `sh <script>` dans `<root>/` | le script gère son propre déploiement ; `am` repère les binaires résultants |
|
||||
| `binary` | télécharge un asset release (`url` ou `repo`), vérifie le `sha256` si fourni, extrait dans `<root>/dist/`, copie dans `bin/` | binaire unique dans `bin/` |
|
||||
| `git` | `git clone --depth 1 <repo> <root>/repo`, exécute les commandes `build`, puis copie `binary_path` dans `bin/` ou crée un shim | `bin/<agent>` ou shim |
|
||||
|
||||
5. **Post-installation** — `am` exécute les commandes `post_install` déclarées dans la fiche, avec `<bindir>` en tête du `PATH`.
|
||||
6. **Détection de version** — `am` lance `<binaire> --version` (puis `-V`) pour enregistrer la version réelle.
|
||||
7. **Enregistrement** — une entrée est écrite dans `state.json` (`name`, `version`, `method`, `install_dir`, `bins`, `run`, `installed_at`, `pid`, etc.).
|
||||
|
||||
#### Cycle de gestion (`am start / stop / update / uninstall`)
|
||||
|
||||
- **Démarrage** — `am start <agent>` lit `state.json` pour retrouver le binaire et la commande `run` configurée. En arrière-plan (`--background`), le PID est enregistré dans `state.json` et la sortie est redirigée vers `<log_dir>/<agent>.log`.
|
||||
- **Arrêt** — `am stop` envoie `SIGTERM`, attend `settings.stop_timeout_secs` (5 s par défaut), puis `SIGKILL` si besoin. Le PID est effacé de `state.json`.
|
||||
- **Mise à jour** — `am update <agent>` détermine la dernière version disponible (registre npm/PyPI/cargo/release), crée une sauvegarde (`am update --rollback`), réinstalle avec la même méthode et met à jour `state.json`. Les versions épinglées (`pin_version`) sont respectées.
|
||||
- **Désinstallation** — `am uninstall <agent>` arrête le processus, supprime `<root>/`, supprime les binaires de `bin/` et efface l'entrée de `state.json`. `--purge` efface aussi les logs et retire la définition du fichier de config utilisateur.
|
||||
|
||||
### 🚀 Exécuter
|
||||
|
||||
| Commande | Rôle |
|
||||
@@ -165,6 +238,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am config show / edit / add / set | gère votre configuration (hooks on_start/on_stop/… dans settings.hooks) |
|
||||
| am open <agent> | ouvre le répertoire d'installation dans l'explorateur |
|
||||
| am completion <shell> [--installed] | script de complétion (bash, zsh, fish, powershell, elvish) ; --installed complète dynamiquement les agents installés, alias et groupes |
|
||||
| am serve --token [--port <p>] | API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask…) — rate limiting par IP, TLS via reverse proxy |
|
||||
| am self-update | met à jour am lui-même (si configuré) |
|
||||
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
|
||||
| am | shell interactif : bannière, passerelle système, Tab et historique |
|
||||
@@ -309,7 +383,7 @@ supprimez aussi ces emplacements.)
|
||||
1. --config <fichier>
|
||||
2. ./agent-manager.yaml (répertoire courant)
|
||||
3. ~/.config/agent-manager/config.yaml
|
||||
4. catalogue embarqué (72 agents)
|
||||
4. catalogue embarqué (78 agents)
|
||||
|
||||
Vos définitions **complètent ou surchargent** le catalogue par nom.
|
||||
|
||||
|
||||
+386
-13
@@ -1,6 +1,13 @@
|
||||
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
||||
|
||||
> **Version du document : 2.2** · Statut : propositions, non engagées
|
||||
> **Version du document : 3.1** · Statut : phases 0 à 3 livrées
|
||||
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.5
|
||||
>
|
||||
> 🧭 **Bilan du 2026-08-23** : tout le périmètre versionné (J0 → J3, v0.7.0,
|
||||
> épique v1.1.0) est livré — **0 issue ouverte** sur Gitea. Il reste :
|
||||
> **1 bug prioritaire** (hang de `cargo test` / `am doctor`, §15.4),
|
||||
> 3 fonctionnalités P1 jamais découpées en issues (§15.2), des activités de
|
||||
> release (§15.3) et des idées neuves proposées (§16).
|
||||
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
||||
>
|
||||
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
||||
@@ -13,6 +20,141 @@
|
||||
> profils d'environnement (#40), les complétions dynamiques + man pages
|
||||
> (#41) et le packaging officiel + CI (#42). Binaires Windows/Linux publiés
|
||||
> sur Gitea — am self-update et les installateurs servent la v0.4.1.
|
||||
>
|
||||
> ✅ **Phase 2 livrée en v0.6.0 (2026-08-19)** : 28/28 issues clôturées
|
||||
> (#47–#75), 250+ tests. am web, i18n, services, schedule, lab, sync,
|
||||
> migrate, models, plugins d'événements… Binaires publiés sur Gitea.
|
||||
>
|
||||
> ✅ **Phase v0.7.0 livrée (2026-08-19)** : 5/5 issues clôturées (#88–#92) —
|
||||
> registre de providers LLM (am providers), secrets partagés par provider,
|
||||
> flags --provider/--model/--no-config à l'install, configuration
|
||||
> post-install (bloc config:).
|
||||
>
|
||||
> ✅ **Phase 3 livrée en v1.0.0 (2026-08-20)** : 5/5 issues clôturées
|
||||
> (#76–#80) — télémétrie opt-in, am registry, am ask, profils sandbox,
|
||||
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
|
||||
> archives, prompt REPL avec shell actif.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
|
||||
> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect`
|
||||
> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python),
|
||||
> plus de faux « external » ; docs alignées.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.4 (2026-08-20)** : catalogue enrichi — 5 agents
|
||||
> **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub
|
||||
> Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI
|
||||
> (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée**
|
||||
> (issues #96 #97) — langage naturel → action shell via AIChat : mode
|
||||
> conversationnel (aichat -f <ctx>), mode --exec avec pipeline de sécurité
|
||||
> (génération aichat --dry-run, classification safe/risky + certainité,
|
||||
> politique dry-run par défaut configurable settings.shell_ai, confirmation
|
||||
> y/N pour les commandes risky, exécution via le shell de l'utilisateur),
|
||||
> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements
|
||||
> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.6 (2026-08-20)** : installateur Windows corrigé —
|
||||
> `install.ps1` n'acceptait que l'entrée `am.exe` dans l'archive de release
|
||||
> (la CI et les archives manuelles utilisent `am-windows-x86_64.exe`) →
|
||||
> repli systématique sur `cargo install` au lieu du binaire précompilé.
|
||||
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
|
||||
> les deux entrées pour la compatibilité maximale. Chemin du message
|
||||
> cargo install corrigé (`.cargo\bin\am.exe`).
|
||||
>
|
||||
> 🚀 **Épique v1.1.0 (2026-08-20)** : **Onboarding, Copilot & Rôles aichat**
|
||||
> (Axe 14) — `am setup` (wizard provider + token keyring + modèle + ping),
|
||||
> installation automatique d'aichat (wizard + `am ai` + installateurs),
|
||||
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
|
||||
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
|
||||
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
|
||||
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
|
||||
> validateur : « unknown field `providers` »). Le registre vivant sous
|
||||
> `settings.providers`, le wizard écrit désormais
|
||||
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
|
||||
> base_url quand un bloc provider est écrit). Test de régression
|
||||
> `setup_persists_under_settings_providers`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.2 (2026-08-20)** :
|
||||
> - `am setup` : **openrouter** ajouté au choix des providers (base URL,
|
||||
> token via OPENROUTER_API_KEY, 6 modèles déclarés proposés) ;
|
||||
> le choix du modèle liste les modèles disponibles du provider.
|
||||
> - `am ai` sans `--role` utilise **am-copilot par défaut** (régénération
|
||||
> automatique des rôles si le fichier manque, dégradation douce).
|
||||
> - Bug `am migrate --import` : la config importée était écrite dans le
|
||||
> répertoire config GLOBAL (user_config_dir) au lieu du config actif de
|
||||
> l'app — l'import écrasait la config réelle de la machine (et la suite
|
||||
> de tests détruisait la config utilisateur à chaque `cargo test`).
|
||||
> L'import respecte désormais `app.paths.config_dir` (--config/isolated).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.3 (2026-08-21)** : fix `resolve_exec` — pour un
|
||||
> agent INSTALLÉ avec un binaire enregistré, le premier token de `run:`
|
||||
> (ex. `picoclaw`) n'était jamais consommé : `am start <agent>` lançait
|
||||
> `<bin> <token-run>` (picoclaw rejetait son propre nom comme
|
||||
> sous-commande inconnue ; aichat recevait « aichat » comme prompt
|
||||
> one-shot). Le token est désormais consommé dans la branche « binaire
|
||||
> enregistré existant » (les branches non-installé/binaire manquant
|
||||
> étaient déjà correctes). Test de régression
|
||||
> `resolve_exec_installed_agent_does_not_leak_run_token`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.4 (2026-08-21)** : hints de configuration
|
||||
> post-install — nouveau champ `setup_hints` dans la définition d'un agent
|
||||
> (config.yaml) : commandes de configuration manuelle affichées après
|
||||
> l'install quand l'agent gère sa propre config (ex. picoclaw
|
||||
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
||||
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
||||
> premier agent du catalogue à les déclarer.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec
|
||||
> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown
|
||||
> `agents/<name>.md` ≤ 0.29 est remplacé par une définition
|
||||
> `functions/agents/<name>/index.yaml` + un registre `functions/agents.txt`).
|
||||
> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats
|
||||
> (compatibilité totale) et `am ai` détecte la définition nouvelle génération
|
||||
> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte
|
||||
> par défaut (dossier courant) est filtré par am (`.git`, `target`,
|
||||
> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB)
|
||||
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
|
||||
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
|
||||
> n'appelle plus l'API, il écho la requête).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.6 (2026-08-24)** : correctifs de robustesse —
|
||||
> timeout de 30 s sur les commandes capturées (doctor/runner ne pendent plus
|
||||
> si un agent ne répond pas, pipes vidés dans des threads pour éviter les
|
||||
> deadlocks), `is_running` Windows via l'API Win32 (OpenProcess /
|
||||
> GetExitCodeProcess, plus fiable que tasklist), rejet des chemins unsafe
|
||||
> (path traversal) dans `am migrate --import`, frames WebSocket RFC 6455
|
||||
> avec vraies longueurs > 125 octets, boucle de traduction i18n de la page
|
||||
> web corrigée (plus de boucle infinie), index du trousseau (keyring)
|
||||
> sérialisé en JSON (clés contenant des virgules).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.7 (2026-09-01)** : Gemini CLI a cessé de servir les
|
||||
> comptes Google gratuits/Pro/Ultra le 2026-06-18 — remplacé par **Antigravity
|
||||
> CLI** (`agy`, binaire Go fermé, même harnais qu'Antigravity 2.0). Nouveau
|
||||
> catalogue : entrée `antigravity-cli` (release GitHub
|
||||
> google-antigravity/antigravity-cli), `gemini-cli` marqué déprécié
|
||||
> (`installable: false`, clé API payante seulement), alias `gemini`/`agy`
|
||||
> redirigés, groupe `mainstream` et gabarit web mis à jour. Import des
|
||||
> extensions/skills Gemini : `agy plugin import gemini`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.8 (2026-09-01)** : providers **QwenCloud** ajoutés au
|
||||
> registre par défaut — `qwen-cloud` (pay-as-you-go, Model Studio/DashScope,
|
||||
> dashscope-intl…/compatible-mode/v1) et `qwen-cloud-token-plan` (abonnement
|
||||
> Credits, token-plan.ap-southeast-1…/compatible-mode/v1, modèles restreints
|
||||
> dont qwen3.8-max). `qwen-code` pointe sur qwen-cloud par défaut (env_map
|
||||
> OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer
|
||||
> sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via
|
||||
> `am providers set-token`).
|
||||
>
|
||||
> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses
|
||||
> providers dans une constante codée en dur — les QwenCloud ajoutés au
|
||||
> registre n'y apparaissaient pas. La liste est maintenant construite depuis
|
||||
> le registre fusionné (embarqué + config utilisateur), ollama et custom en
|
||||
> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne
|
||||
> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion
|
||||
> `--yes`).
|
||||
|
||||
---
|
||||
|
||||
@@ -29,12 +171,14 @@
|
||||
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
|
||||
10. [Risques & garde-fous](#10--risques--garde-fous)
|
||||
11. [Par où commencer](#11--par-où-commencer)
|
||||
12. [Bilan & reste à faire (2026-08-23)](#15--bilan--reste-à-faire-analyse-du-2026-08-23)
|
||||
13. [Idées neuves (post-v1.1.5)](#16--idées-neuves-post-v115)
|
||||
|
||||
---
|
||||
|
||||
## 1. 📌 Résumé exécutif
|
||||
|
||||
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
|
||||
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 77 agents au
|
||||
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
|
||||
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
|
||||
|
||||
@@ -96,7 +240,7 @@ quelques jours, à caler avant ou pendant la construction du journal.*
|
||||
|
||||
| Brique actuelle | Fichier | Limite aujourd'hui |
|
||||
|---|---|---|
|
||||
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
|
||||
| Catalogue 77 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
|
||||
| Installation (9 méthodes), dépendances OS | src/installers/, src/deps.rs, src/toolchain.rs | solide — rien à redire |
|
||||
| État local | src/state.rs (state.json v1) | installations + PID courant uniquement, **aucun historique** |
|
||||
| Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
|
||||
@@ -258,8 +402,8 @@ consultable, reprenable, archivable.
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
|
||||
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 |
|
||||
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 |
|
||||
| ⬜ am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) — **non livré, sans issue** | S | P1 |
|
||||
| ⬜ am news — dernières releases des agents installés (API GitHub/Gitea, cache) — **non livré, sans issue** | M | P1 |
|
||||
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
|
||||
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
|
||||
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
|
||||
@@ -298,7 +442,7 @@ consultable, reprenable, archivable.
|
||||
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
|
||||
| Notes : am note claude-code "utiliser pour …" | S | P1 |
|
||||
| Tags personnels : am tag claude-code python | S | P1 |
|
||||
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 |
|
||||
| ⬜ Raccourcis de commandes : settings.shortcuts (i → install, s → start) — **non livré, sans issue** | S | P1 |
|
||||
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
|
||||
| Thèmes couleurs du REPL | S | P2 |
|
||||
|
||||
@@ -328,6 +472,206 @@ consultable, reprenable, archivable.
|
||||
|
||||
---
|
||||
|
||||
### Axe 13 — ⚡ Shell AI & Action ⭐
|
||||
|
||||
🎯 Transformer une commande en langage naturel en une action shell exécutée par un agent léger, rapide et contextuel, sans démarrer un gros coding agent.
|
||||
|
||||
**Contexte :** `am` gère aujourd'hui ~77 agents IA, principalement des *coding agents*, des assistants et des outils SaaS. Les shell assistants sont maintenant au catalogue (AIChat, ShellGPT, Fabric, Shell AI, AI CLI — v1.0.4) : légers, rapides, conçus pour transformer une phrase en commande ou action sur le système de fichiers local (ex. *"traite les fichiers JSON du dossier courant pour extraire les clés uniques"*).
|
||||
|
||||
---
|
||||
|
||||
#### 13.1 Agents shell AI découverts (✅ tous ajoutés au catalogue — v1.0.4, issues #94 #95)
|
||||
|
||||
| Outil | Repo | Langage | Force | Maturité |
|
||||
|---|---|---|---|---|
|
||||
| ✅ **AIChat** | `sigoden/aichat` | Rust | Shell assistant natif, RAG, agents, fichiers/répertoires, 20+ providers | **10.4k stars**, très actif |
|
||||
| ✅ **ShellGPT** | `TheR1D/shell_gpt` | Python | Génère/exécute commandes shell, code, docs | Très connu, mature |
|
||||
| ✅ **Fabric** | `danielmiessler/fabric` | Go | Patterns AI (summarize, extract wisdom), CLI | Très populaire, orienté contenu/texte |
|
||||
| ✅ **Shell AI** | `nishant9083/shell-ai` | TypeScript | Agent ReAct local via Ollama, MCP, filesystem tools | Plus récent, prometteur mais jeune |
|
||||
| ✅ **AI CLI** | `kriserickson/ai-cli` | Go | Natural language → commandes shell avec safety policy (`risk` / `certainty`) | Petit, simple, moins connu |
|
||||
|
||||
---
|
||||
|
||||
#### 13.2 Recommandation : AIChat (`sigoden/aichat`)
|
||||
|
||||
**Choix privilégié pour la fonction Shell AI.**
|
||||
|
||||
| Avantage | Détails |
|
||||
|---|---|
|
||||
| **Léger & rapide** | Rust, binaire unique, cold start rapide, idéal pour RPi 4 |
|
||||
| **Shell Assistant natif** | Génère et exécute des commandes shell à partir du langage naturel |
|
||||
| **Multi-provider** | OpenAI, Claude, Gemini, DeepSeek, Groq, Ollama, OpenRouter… |
|
||||
| **Passage de contexte** | `aichat -f .` injecte le dossier ou les fichiers dans le prompt |
|
||||
| **Mode exécution** | `aichat -e "..."` exécute directement, mode conversationnel sinon |
|
||||
| **Facilité d'intégration** | Release binaire GitHub, installable via `binary` dans le catalogue |
|
||||
|
||||
Exemples d'usage ciblés :
|
||||
|
||||
```bash
|
||||
# Lister et traiter les fichiers JSON du dossier courant
|
||||
aichat -f . -e "liste tous les fichiers JSON et extrait les clés uniques"
|
||||
|
||||
# Résumer un dossier de fichiers
|
||||
aichat -f . "résume les données de ces fichiers JSON"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 13.3 Fonctionnalités prévues
|
||||
|
||||
| Fonctionnalité | Description | Effort | Phase |
|
||||
|---|---|---|---|
|
||||
| ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 |
|
||||
| ✅ **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| ✅ **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 |
|
||||
| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 |
|
||||
| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 |
|
||||
|
||||
---
|
||||
|
||||
#### 13.4 Risques & garde-fous
|
||||
|
||||
| Risque | Mitigation |
|
||||
|---|---|
|
||||
| Exécution automatique de commandes dangereuses | `--dry-run` par défaut, confirmation obligatoire avant toute commande `risky` |
|
||||
| Coût API récurrent | Modèle cheap par défaut (`gpt-4.1-mini`, `gemini-flash`) ou Ollama local |
|
||||
| Fuite de données sensibles | Support du mode local Ollama, pas d'envoi hors du provider configuré |
|
||||
| Installation lente sur RPi | Utiliser l'installateur `binary` GitHub Releases plutôt que `cargo` |
|
||||
|
||||
---
|
||||
|
||||
#### 13.5 Idées sœurs (à intégrer ou lier à d'autres axes)
|
||||
|
||||
| Commande | Description | Axe lié |
|
||||
|---|---|---|
|
||||
| `am summarize <fichier/dossier>` | Résume un fichier ou un dossier via AIChat/Fabric | Axe 13 |
|
||||
| `am explain <commande>` | Explique une commande shell avant exécution | Axe 13 |
|
||||
| `am commit` | Génère un message de commit depuis `git diff` | Axe 13 / Axe 3 |
|
||||
| `am review` | Review rapide d'un diff ou d'une PR | Axe 7 |
|
||||
| `am translate <fichier> --to en` | Traduit un fichier markdown/doc | Axe 13 |
|
||||
| `am ask-code "..."` | Pose une question sur le codebase sans lancer un gros agent | Axe 7 |
|
||||
| `am doc <dossier>` | Génère un README/doc à partir du code | Axe 13 / Axe 7 |
|
||||
| `am fix` | Corrige une commande shell qui a échoué | Axe 13 |
|
||||
| `am note` | Extrait des action items d'un texte/réunion | Axe 10 |
|
||||
| `am search-web "..."` | Recherche web rapide et réponse synthétisée | Axe 7 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 🚀 Onboarding & Copilot aichat (épique v1.1.0)
|
||||
|
||||
> Objectif : réduire le **Time-To-First-Value à 0** — rendre `am` opérationnel
|
||||
> pour l'IA locale dès la première seconde, et faire d'aichat le copilot de
|
||||
> agent-manager. Livré en **v1.1.0 (2026-08-20)**.
|
||||
|
||||
| Fonctionnalité | Description | Statut |
|
||||
|---|---|---|
|
||||
| ✅ **Wizard d'onboarding (`am setup`)** | Provider (anthropic, openai, deepseek, google, ollama, custom), token masqué → trousseau OS (keyring, jamais en clair), modèle par défaut, ping API non-bloquant. Déclenché par `am ai`/`am ask`/banner REPL quand aucun provider n'est configuré (message discret, jamais bloquant), ré-exécutable à la main (mode revoir) | ✅ v1.1.0 |
|
||||
| ✅ **Installation automatique d'aichat** | Étape du wizard « Installer le moteur IA (aichat) ? [Y/n] » → `am install aichat` (catalogue) ; `am ai` sans aichat propose l'installation ; install.ps1/install.sh enchaînent sur `am setup` si stdin interactif | ✅ v1.1.0 |
|
||||
| ✅ **Rôles copilot aichat (am-*)** | 6 agents générés dans `~/.config/aichat/agents/` (ou %APPDATA%\\aichat\\agents) : am-copilot (guide & catalogue), am-operator (shell sécurisé), am-dev (code & git), am-do (exécution pure pour --exec), am-analyst (coûts/logs/stats), am-orchestrator (groupes/lab). Prompts bilingues (FR par défaut), privilégient les contrats `--json` | ✅ v1.1.0 |
|
||||
| ✅ **`am ai --role <nom>`** | Rôle copilot passé à aichat (`--agent`) ; validation du fichier généré ; complétion REPL des rôles am-* ; config aichat créée (provider + modèle) seulement si absente | ✅ v1.1.0 |
|
||||
| ✅ **Settings dédiés** | `settings.shell_ai` (default_safety dry-run/confirm/auto, risky_patterns) documenté dans config.yaml ; module `src/setup.rs` + `src/roles.rs` | ✅ v1.1.0 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 🧭 Bilan & reste à faire (analyse du 2026-08-23)
|
||||
|
||||
> Analyse complète du dépôt le 2026-08-23 : code (v1.1.5, commit e637c06),
|
||||
> historique git, tracker Gitea, artefacts de release.
|
||||
|
||||
### 15.1 ✅ Ce qui est livré
|
||||
|
||||
- **Tout le périmètre versionné** : J0/v0.3.0, J1/v0.4.1, J2/v0.6.0,
|
||||
v0.7.0 (providers), J3/v1.0.0, épique v1.1.0 (onboarding/copilot) et les
|
||||
maintenances v1.0.1 → v1.1.5.
|
||||
- **0 issue ouverte** sur le tracker Gitea (issues #1 → #97 clôturées).
|
||||
- `main` synchronisé avec `origin/main` ; Cargo.toml/Cargo.lock en
|
||||
**v1.1.5** ; suite de tests : **455 tests passés, 0 échec** le
|
||||
2026-08-23 (23 suites, hors `doctor_test` qui hang — §15.4).
|
||||
- Vérifié présent dans le code : favoris/notes/tags (#39), dashboard,
|
||||
monitor, web, serve, lab, sync, migrate, models, sandbox, registry,
|
||||
ask, ai, setup + rôles copilot, self-update, services, schedule, i18n,
|
||||
packaging, complétions, man pages.
|
||||
|
||||
### 15.2 ⬜ Fonctionnalités du roadmap NON livrées (jamais découpées en issues)
|
||||
|
||||
| Fonctionnalité | Axe | Effort | Constat dans le code |
|
||||
|---|---|---|---|
|
||||
| `am compare a b` — tableau côte à côte | Axe 7 | S | aucune commande `Compare` dans `src/cli.rs` |
|
||||
| `am news` — dernières releases des agents installés | Axe 7 | M | aucun module, 0 occurrence dans `src/` |
|
||||
| `settings.shortcuts` — raccourcis REPL (i → install…) | Axe 10 | S | 0 occurrence de `shortcuts` dans `src/` |
|
||||
| Extensions sœurs Axe 13 (`am summarize`, `am explain`, `am fix`, …) | Axe 13 | M | absentes — voir §13.5 et l'idée N10 (§16) |
|
||||
|
||||
➡️ Suggestion : créer 3 issues Gitea (compare, news, shortcuts) en P1 —
|
||||
petits efforts à forte valeur, seuls restes des jalons « cockpit ».
|
||||
|
||||
### 15.3 📦 Activités de release / opérations
|
||||
|
||||
| Activité | Détail |
|
||||
|---|---|
|
||||
| **Publier la release v1.1.5** | Le commit `e637c06` (fix aichat ≥ 0.30) est sur `main` **sans tag ni release Gitea** → installateurs et `am self-update` servent encore la v1.1.4. Taguer `v1.1.5` pour déclencher `.gitea/workflows/release.yml`. |
|
||||
| **Compléter les assets v1.1.4** | La release v1.1.4 publiée ne contient **que** `am-windows-x86_64.zip` : binaires Linux (x86_64/aarch64) et macOS absents → les installateurs Linux/macOS retombent sur `cargo install --git`. |
|
||||
| **Artéfacts `dist/` obsolètes** | Les archives de `dist/` datent de l'ère v0.6.0 (2026-08-19) ; laisser la CI régénérer celles de v1.1.5. |
|
||||
| **Stash obsolète** | `stash@{0}` « WIP issue #39 : favoris, notes, tags » (2026-08-17 09:12) — la fonctionnalité a été livrée autrement dans v0.4.1 (champs présents dans `src/state.rs`) ; à purger (`git stash drop`) après confirmation. |
|
||||
|
||||
### 15.4 🐛 Bug découvert pendant l'analyse (2026-08-23) — à corriger en priorité
|
||||
|
||||
**`cargo test` se suspend** sur `doctor_test::report_contains_core_checks`
|
||||
sur cette machine (reproduit 3 fois, dont une exécution > 20 min), et le
|
||||
même hang peut frapper l'utilisateur final via `am doctor`.
|
||||
|
||||
- **Cause racine** : `SystemRunner::run` (`src/runner.rs`, branche
|
||||
`capture`) attend `Command::output()` **sans timeout** et sans
|
||||
protection contre l'héritage des pipes. `am doctor` sonde les outils du
|
||||
PATH (`ollama ls`, `llama-server --version`, `git --version`…) ; quand
|
||||
le processus fils engendre un petit-enfant qui conserve les handles
|
||||
stdout/stderr (cas réel observé : `conhost.exe` alloué pour
|
||||
`ollama.exe ls` lancé sans console), `output()` attend l'EOF des pipes
|
||||
**indéfiniment**.
|
||||
- **Reproduction** : `target\debug\deps\doctor_test-*.exe --nocapture
|
||||
report_contains_core_checks` → hang > 40 s avec enfants `ollama.exe ls`
|
||||
+ `conhost.exe` sous le processus de test ; l'ancien binaire de test du
|
||||
2026-08-21 passait en 2,2 s (déclenchement du conhost non déterministe).
|
||||
- **Périmètre touché** : tout ce qui passe par `SystemRunner::run` avec
|
||||
capture — `am doctor` donc, et les sondes d'installation. (`am models`,
|
||||
`am lab`, les plugins et la sonde externe utilisent déjà `wait_timeout`
|
||||
et sont immunisés.)
|
||||
- **Correctif proposé** : aligner `SystemRunner::run` sur le pattern déjà
|
||||
éprouvé dans `src/models.rs` / `src/lab.rs` / `src/plugins.rs` : spawn
|
||||
+ `wait_timeout` (30 s par défaut, `settings.probe_timeout_secs`) +
|
||||
drain des pipes avant l'attente + kill de l'arbre à l'expiration — les
|
||||
commentaires de `lab.rs` et `plugins.rs` documentent exactement ce
|
||||
deadlock. À compléter par un test de régression (faux binaire qui
|
||||
engendre un enfant garde-fou).
|
||||
- **Contournement immédiat** : lancer les suites une à une, sans
|
||||
`--test doctor_test`.
|
||||
|
||||
---
|
||||
|
||||
## 16. 💡 Idées neuves (post-v1.1.5)
|
||||
|
||||
> Ajoutées lors de l'analyse du 2026-08-23. Toutes ⬜ (proposées), non
|
||||
> planifiées. Respectent l'ADN du projet : zéro dépendance runtime, local
|
||||
> par défaut, contrats `--json` stables.
|
||||
|
||||
| # | Idée | Description | Axe | Effort | Phase |
|
||||
|---|---|---|---|---|---|
|
||||
| N1 | ⬜ **`am mcp` — serveur MCP** | Exposer am comme serveur *Model Context Protocol* : les agents (Claude Code, Codex, aichat…) découvrent et pilotent le parc via des outils MCP (`am__list`, `am__stats`, `am__sessions`, `am__start`…). am devient le hub de coopération entre agents. | nouveau | L | P3 |
|
||||
| N2 | ⬜ **Budgets & alertes de coûts** | Budget par agent/projet avec alertes à seuils (80 % / 100 %) et blocage doux, branché sur le suivi des coûts (#49). | Axe 1 | M | P2 |
|
||||
| N3 | ⬜ **Webhooks de notification** | Alertes `am watch` / `schedule` / `doctor --watch` vers un webhook (Discord, Slack, n8n, Gotify) en plus du `--notify` local. | Axe 6 | S | P2 |
|
||||
| N4 | ⬜ **`am test <agent>` — smoke test** | Vérification normalisée post-install/post-update : le binaire répond, ping provider, tâche minimale — généralise le ping de `am setup` à tout le catalogue. | Axe 7 | M | P2 |
|
||||
| N5 | ⬜ **`am diff` — comparaison de configs** | Diff de config/état entre deux machines ou deux points de sync ; complète `am sync` (#66) et `am migrate` (#68) pour le multi-machine. | Axe 9 | S | P2 |
|
||||
| N6 | ⬜ **Transcripts de session** | `am sessions transcript <id>` : export Markdown lisible et partageable d'une session (commandes + sorties), complément de `sessions export` (#53). | Axe 4 | S | P2 |
|
||||
| N7 | ⬜ **`am doctor --report`** | Rapport de diagnostic complet anonymisé (versions, doctor, événements récents) prêt à joindre à un bug report. | Axe 12 | S | P2 |
|
||||
| N8 | ⬜ **Catalogue air-gapped** | `am catalog snapshot` : instantané hors-ligne complet du catalogue distant (définitions + binaires) pour machines sans internet. | Axe 7 | M | P3 |
|
||||
| N9 | ⬜ **Export OpenTelemetry** | Export opt-in du journal `events.jsonl` vers OTLP/Grafana pour les équipes qui ont déjà une pile d'observabilité. | Axe 1 | M | P3 |
|
||||
| N10 | ⬜ **Épique « Boîte à outils IA »** | Formaliser les commandes sœurs §13.5 sur les rôles copilot aichat (v1.1.0) : `am commit`, `am review`, `am explain`, `am fix`, `am summarize`, `am doc`, `am translate`, `am ask-code`, `am search-web`, `am note`. Une commande = un rôle + un raccourci de prompt, sécurité `--exec` héritée de `am ai`. | Axe 13 | L | P2 → P3 |
|
||||
| N11 | ⬜ **Suite de tests 100 % hermétique** | `update_policy_test` (> 60 s/test) appelle réellement l'API GitHub via `installers::latest_version` ; prévoir un mode hors-ligne des sondes de version (flag test/offline ou mock), pour une suite rapide et déterministe partout. | Axe 12 | S | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 🚀 Jalons versionnés
|
||||
|
||||
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|
||||
@@ -467,16 +811,45 @@ alerte).
|
||||
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
|
||||
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
|
||||
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
|
||||
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | ✅ Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
|
||||
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | ✅ Profils sandbox par agent (commandes/répertoires autorisés) | L |
|
||||
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | ✅ am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
|
||||
|
||||
Critère de sortie du jalon : dashboard web complet + API distante.
|
||||
|
||||
### Phase v0.7.0 — Providers & configuration automatisée (P0, PRIORITAIRE)
|
||||
|
||||
Épique : [issue #87](https://git.dracodev.net/Projets/agent-manager/issues/87)
|
||||
· milestone v0.7.0 · 5 issues · **priorité P0 : livrée AVANT la Phase 3
|
||||
(v1.0, #76–#80 en P3), sauf dépendances**.
|
||||
|
||||
Centralise tokens/providers/modèles dans UN registre (settings.providers +
|
||||
keyring partagé) et configure automatiquement les agents à l'installation :
|
||||
`am install <agent>` branche le provider/modèle par défaut,
|
||||
`--provider/--model` pour surcharger, `--no-config` pour le comportement
|
||||
actuel, `am run --provider` pour surcharger au lancement (cloud inclus).
|
||||
S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés.
|
||||
|
||||
| # | Issue | Effort | Dépend de |
|
||||
|---|---|---|---|
|
||||
| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — |
|
||||
| [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | ✅ Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 |
|
||||
| [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | ✅ AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 |
|
||||
| [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | ✅ Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 |
|
||||
| [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | ✅ `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 |
|
||||
|
||||
Critère de sortie du jalon : un agent installé est opérationnel sans
|
||||
configuration manuelle (tokens au keyring, provider/modèle par défaut).
|
||||
|
||||
---
|
||||
|
||||
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
|
||||
La phase 0 est livrée (v0.3.0) ; les découpages des phases 1 (v0.4.0),
|
||||
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).*
|
||||
Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers,
|
||||
2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2
|
||||
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
|
||||
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
|
||||
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe),
|
||||
> v1.1.0 (onboarding/copilot) → v1.1.5 (aichat ≥ 0.30). Bilan & idées
|
||||
> neuves ajoutés le 2026-08-23 (§15, §16).*
|
||||
|
||||
+224
-5
@@ -43,24 +43,117 @@ settings:
|
||||
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
|
||||
# sync_repo: https://git.dracodev.net/bruno/am-state.git
|
||||
# sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in)
|
||||
# Télémétrie anonyme opt-in (#76) : compteurs agrégés uniquement (jamais de
|
||||
# chemins, commandes ni identifiants). Désactivée par défaut — RIEN n'est
|
||||
# collecté ni envoyé tant que enabled n'est pas explicitement true.
|
||||
# telemetry:
|
||||
# enabled: false
|
||||
# endpoint: https://exemple.tld/v1/ping # optionnel : envoi batch périodique
|
||||
# am ask (#78) : langage naturel → commande am. Les règles locales marchent
|
||||
# toujours, hors-ligne, sans dépendance. Le raffinement LLM optionnel passe
|
||||
# par le registre providers (base_url + token du keyring + modèle) — seul
|
||||
# le texte de la requête est envoyé, jamais les données du journal.
|
||||
# ask:
|
||||
# enabled: true # false désactive entièrement am ask
|
||||
# provider: deepseek # optionnel (défaut: settings.default_provider)
|
||||
# model: deepseek-chat # optionnel (défaut: modèle par défaut du provider)
|
||||
# Registre communautaire (#77) : sources de catalogues de confiance +
|
||||
# auteur par défaut pour am registry publish. L'installation d'une source
|
||||
# inconnue exige une confirmation (checksum sha256 vérifié avant tout).
|
||||
# registry:
|
||||
# sources:
|
||||
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
|
||||
# author: bruno
|
||||
# am ai (#96 #97) : politique de sécurité Shell AI. dry-run par défaut —
|
||||
# aucune commande modifiante n'est exécutée sans confirmation explicite
|
||||
# (--yes). `confirm` demande validation pour les commandes risky ;
|
||||
# `auto` exécute tout (déconseillé). Les motifs supplémentaires s'ajoutent
|
||||
# aux patterns intégrés (rm -rf, dd if, mkfs, chmod -R 777, ...).
|
||||
# shell_ai:
|
||||
# default_safety: dry-run # dry-run | confirm | auto
|
||||
# risky_patterns:
|
||||
# - "rm -rf"
|
||||
# - "> /dev"
|
||||
# - "dd if"
|
||||
# Profil sandbox par agent (#79) : commandes autorisées (binaires), répertoires
|
||||
# de travail et politique réseau. Désactivé par défaut (mode non sandboxé).
|
||||
# Les tentatives refusées sont journalisées (events sandbox) pour l'audit.
|
||||
# Exemple dans un agent :
|
||||
# sandbox:
|
||||
# enabled: true
|
||||
# commands: [python, git]
|
||||
# dirs: ["~/workspace"]
|
||||
# network: false
|
||||
# Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par
|
||||
# défaut. Le provider par défaut est utilisé à l'install/au run quand aucun
|
||||
# n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans
|
||||
# le keyring OS sous providers/<nom>/api_key (issue #89, am providers set-token).
|
||||
# Pour retirer un provider du catalogue par défaut : am providers remove <nom>
|
||||
# (écrit "<nom>: null" dans votre config — le null supprime l'entrée au merge).
|
||||
default_provider: anthropic
|
||||
providers:
|
||||
anthropic:
|
||||
base_url: https://api.anthropic.com/v1
|
||||
default_model: claude-sonnet-4-5
|
||||
models: [claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5]
|
||||
openai:
|
||||
base_url: https://api.openai.com/v1
|
||||
default_model: gpt-5.2
|
||||
models: [gpt-5.2, gpt-5.1-mini, o4-mini]
|
||||
deepseek:
|
||||
base_url: https://api.deepseek.com
|
||||
default_model: deepseek-chat
|
||||
models: [deepseek-chat, deepseek-reasoner]
|
||||
google:
|
||||
base_url: https://generativelanguage.googleapis.com/v1beta
|
||||
default_model: gemini-2.5-pro
|
||||
models: [gemini-2.5-pro, gemini-2.5-flash]
|
||||
# QwenCloud (Alibaba) — deux modes de facturation, deux providers :
|
||||
# - qwen-cloud-token-plan : abonnement Credits (Personal/Team), clé
|
||||
# dédiée Token Plan, modèles restreints (voir docs.qwencloud.com).
|
||||
# Base URL internationale (ap-southeast-1) ; la variante Pékin
|
||||
# token-plan.cn-beijing.maas.aliyuncs.com sert la Chine.
|
||||
# - qwen-cloud : pay-as-you-go (clé sk- Model Studio / DashScope).
|
||||
qwen-cloud-token-plan:
|
||||
base_url: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
|
||||
default_model: qwen3.8-max
|
||||
models: [qwen3.8-max, qwen3.8-flash, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, deepseek-v4-pro, glm-5.2]
|
||||
qwen-cloud:
|
||||
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
|
||||
default_model: qwen3.7-plus
|
||||
models: [qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, qwen3-coder-plus, qwen3-coder-next, deepseek-v4-pro]
|
||||
openrouter:
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
default_model: deepseek/deepseek-chat-v3-0324
|
||||
models:
|
||||
- deepseek/deepseek-chat-v3-0324
|
||||
- anthropic/claude-sonnet-4.5
|
||||
- openai/gpt-4o
|
||||
- google/gemini-2.5-pro
|
||||
- meta-llama/llama-3.3-70b-instruct
|
||||
- mistralai/mistral-large-2411
|
||||
|
||||
# --- Command aliases ----------------------------------------------------------
|
||||
aliases:
|
||||
cc: claude-code
|
||||
gemini: gemini-cli
|
||||
# Gemini CLI (npm @google/gemini-cli) ne sert plus les comptes Google
|
||||
# gratuits/Pro/Ultra depuis le 2026-06-18 — remplacé par Antigravity CLI.
|
||||
gemini: antigravity-cli
|
||||
agy: antigravity-cli
|
||||
gh-copilot: github-copilot
|
||||
q: amazon-q
|
||||
droid: factory-droid
|
||||
gjc: gajae-code
|
||||
tiny: tiny-agents
|
||||
continue: continue-cli
|
||||
ai: aichat
|
||||
|
||||
# --- Groups (start/stop/restart several agents together) ----------------------
|
||||
groups:
|
||||
dev: [claude-code, aider, codex]
|
||||
local: [atomic-agent, nanocoder, gptme, goose]
|
||||
claw: [openclaw, zeroclaw, picoclaw]
|
||||
mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code]
|
||||
mainstream: [claude-code, opencode, antigravity-cli, codex, qwen-code]
|
||||
|
||||
# ============================================================================
|
||||
# Agents
|
||||
@@ -78,6 +171,12 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: claude
|
||||
provider: anthropic
|
||||
config:
|
||||
env_map:
|
||||
api_key: ANTHROPIC_API_KEY
|
||||
model: ANTHROPIC_MODEL
|
||||
base_url: ANTHROPIC_BASE_URL
|
||||
tags: [anthropic, assistant, source-available, mainstream]
|
||||
|
||||
- name: deepseek-harness
|
||||
@@ -91,6 +190,7 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
|
||||
run: dsh
|
||||
provider: deepseek
|
||||
tags: [deepseek, harness, multi-agent, plugins, web-ui]
|
||||
|
||||
- name: hermes-agent
|
||||
@@ -207,9 +307,26 @@ agents:
|
||||
note: "projet en mode musée — maintenance 100 % automatisée"
|
||||
tags: [rust, clean-room, fork]
|
||||
|
||||
- name: antigravity-cli
|
||||
display_name: "Antigravity CLI"
|
||||
description: "Agent terminal officiel de Google (successseur de Gemini CLI, fermé, binaire Go). Même harnais d'agents qu'Antigravity 2.0 : multi-agents asynchrones, skills, hooks, plugins, MCP. Auth Google via keyring système, quota partagé avec l'app desktop (surveiller /usage)."
|
||||
category: coding-agent
|
||||
website: https://antigravity.google/cli
|
||||
install:
|
||||
type: binary
|
||||
repo: google-antigravity/antigravity-cli
|
||||
binary: agy
|
||||
run: agy
|
||||
provider: google
|
||||
setup_hints:
|
||||
- "agy (premier lancement : setup interactif + auth Google via le trousseau)"
|
||||
- "agy plugin import gemini (importe extensions/skills de l'ancien Gemini CLI)"
|
||||
tags: [google, mainstream, antigravity]
|
||||
note: "Remplace Gemini CLI : le 2026-06-18, Gemini CLI a cessé de servir les comptes Google gratuits/Pro/Ultra. Import des extensions/skills Gemini : 'agy plugin import gemini' ; GEMINI.md et ~/.gemini/ restent lus (config native : ~/.gemini/antigravity-cli/, skills workspace : .agents/skills/, MCP : mcp_config.json avec clé 'serverUrl')."
|
||||
|
||||
- name: gemini-cli
|
||||
display_name: "Gemini CLI"
|
||||
description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche."
|
||||
display_name: "Gemini CLI (déprécié)"
|
||||
description: "Ancien agent terminal Google (Apache-2.0). Ne sert plus les comptes Google gratuits/Pro/Ultra depuis le 2026-06-18 ; reste utilisable avec une clé API Gemini payante. Migrer vers antigravity-cli."
|
||||
category: coding-agent
|
||||
website: https://github.com/google-gemini/gemini-cli
|
||||
install:
|
||||
@@ -218,7 +335,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: gemini
|
||||
tags: [google, mainstream]
|
||||
installable: false
|
||||
tags: [google, deprecated]
|
||||
note: "déprécié — remplacé par Antigravity CLI ('am install antigravity-cli'), voir la note de cette entrée"
|
||||
|
||||
- name: codex
|
||||
display_name: "Codex CLI"
|
||||
@@ -231,6 +350,11 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: codex
|
||||
config:
|
||||
env_map:
|
||||
api_key: OPENAI_API_KEY
|
||||
model: OPENAI_MODEL
|
||||
base_url: OPENAI_BASE_URL
|
||||
tags: [openai, mainstream]
|
||||
|
||||
- name: openhands
|
||||
@@ -347,6 +471,15 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: qwen-code
|
||||
provider: qwen-cloud
|
||||
config:
|
||||
env_map:
|
||||
api_key: OPENAI_API_KEY
|
||||
model: OPENAI_MODEL
|
||||
base_url: OPENAI_BASE_URL
|
||||
setup_hints:
|
||||
- "am install qwen-code --provider qwen-cloud-token-plan (abonnement Credits Token Plan au lieu du pay-as-you-go)"
|
||||
- "am providers set-token qwen-cloud-token-plan (clé dédiée Token Plan ; pay-as-you-go : clé sk- Model Studio)"
|
||||
tags: [alibaba, qwen]
|
||||
|
||||
- name: grok-build
|
||||
@@ -481,6 +614,15 @@ agents:
|
||||
repo: 1ay1/agentty
|
||||
binary: agentty
|
||||
run: agentty
|
||||
config:
|
||||
provider_default: openai
|
||||
env_map:
|
||||
api_key: AGENTTY_API_KEY
|
||||
model: AGENTTY_MODEL
|
||||
base_url: AGENTTY_BASE_URL
|
||||
files:
|
||||
- path: ~/.config/agentty/config.toml
|
||||
keys: [model, base_url]
|
||||
tags: [c++, static, sandbox]
|
||||
|
||||
- name: nullclaw
|
||||
@@ -723,6 +865,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: "npx lazycodex-ai"
|
||||
# Sonde PATH explicite : le premier mot de `run` est npx (interpréteur) —
|
||||
# sans `detect`, npx sur le PATH suffirait à déclarer l'agent « external ».
|
||||
detect: lazycodex-ai
|
||||
note: "s'intègre dans Codex (nécessite @openai/codex)"
|
||||
tags: [harness, codex, plan]
|
||||
|
||||
@@ -795,6 +940,71 @@ agents:
|
||||
note: "étapes de build spécifiques au repo — consultez son README"
|
||||
tags: [simulator, orchestration]
|
||||
|
||||
# ---------------------------------------------------------------- Shell AI
|
||||
- name: aichat
|
||||
display_name: "AIChat"
|
||||
description: "All-in-one LLM CLI avec Shell Assistant, Chat-REPL, RAG, AI Tools & Agents et support fichiers/répertoires (-f). Idéal pour exécuter des micro-tâches shell via langage naturel (issue #94)."
|
||||
category: shell-ai
|
||||
website: https://github.com/sigoden/aichat
|
||||
install:
|
||||
type: binary
|
||||
repo: sigoden/aichat
|
||||
binary: aichat
|
||||
run: aichat
|
||||
tags: [shell, rust, multi-provider, rag, local, fast]
|
||||
|
||||
- name: ai-cli
|
||||
display_name: "AI CLI"
|
||||
description: "Traduit le langage naturel en commandes shell (OpenAI, OpenRouter ou serveur local) avec politique de sécurité (risk / certainty) avant exécution (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/kriserickson/ai-cli
|
||||
install:
|
||||
type: binary
|
||||
repo: kriserickson/ai-cli
|
||||
binary: ai
|
||||
run: ai
|
||||
tags: [shell, go, safety, multi-provider]
|
||||
|
||||
- name: fabric
|
||||
display_name: "Fabric"
|
||||
description: "Framework open source pour augmenter les humains avec l'IA : patterns réutilisables (summarize, extract wisdom, analyse de contenu), CLI Go 43k+ stars (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/danielmiessler/fabric
|
||||
install:
|
||||
type: go
|
||||
package: github.com/danielmiessler/fabric/cmd/fabric@latest
|
||||
dependencies:
|
||||
- { name: go, min_version: "1.22.0", install_hint: "https://go.dev/dl" }
|
||||
run: fabric
|
||||
tags: [patterns, go, content]
|
||||
|
||||
- name: shell-ai
|
||||
display_name: "Shell AI"
|
||||
description: "Agent ReAct local-first propulsé par Ollama : zéro réseau, outils filesystem, exécution de commandes shell et intégration MCP (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/nishant9083/shell-ai
|
||||
install:
|
||||
type: npm
|
||||
package: "@shell-ai/cli"
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: shell-ai
|
||||
note: "nécessite Ollama et un modèle (ollama pull gpt-oss) — agent jeune (15 stars)"
|
||||
tags: [shell, ollama, local, mcp, typescript]
|
||||
|
||||
- name: shellgpt
|
||||
display_name: "ShellGPT"
|
||||
description: "Outil de productivité CLI propulsé par LLM (GPT-5…) : génère et exécute des commandes shell, écrit du code et des docs, 12k+ stars (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/TheR1D/shell_gpt
|
||||
install:
|
||||
type: pip
|
||||
package: shell-gpt
|
||||
dependencies:
|
||||
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
|
||||
run: sgpt
|
||||
tags: [shell, python, multi-provider]
|
||||
|
||||
# ----------------------------------------------------- Écosystème OpenClaw
|
||||
- name: openclaw
|
||||
display_name: "OpenClaw"
|
||||
@@ -820,6 +1030,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
|
||||
run: "python -m nanobot"
|
||||
# Sonde PATH explicite : le premier mot de `run` est python (interpréteur) —
|
||||
# sans `detect`, /usr/bin/python suffirait à déclarer l'agent « external ».
|
||||
detect: nanobot
|
||||
tags: [python, lightweight]
|
||||
|
||||
- name: zeroclaw
|
||||
@@ -857,6 +1070,12 @@ agents:
|
||||
repo: sipeed/picoclaw
|
||||
binary: picoclaw
|
||||
run: picoclaw
|
||||
# PicoClaw gère sa config par sous-commandes (pas de fichier à écrire) :
|
||||
# ces commandes sont affichées après l'install avec {model} substitué.
|
||||
setup_hints:
|
||||
- "picoclaw onboard"
|
||||
- "picoclaw model {model}"
|
||||
- "picoclaw auth login"
|
||||
tags: [go, tiny]
|
||||
|
||||
- name: ironclaw
|
||||
|
||||
+11
-1
@@ -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
@@ -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
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-ask 1 "ask "
|
||||
.SH NAME
|
||||
ask \- Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
|
||||
.SH SYNOPSIS
|
||||
\fBask\fR [\fB\-\-yes\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIREQUEST\fR>
|
||||
.SH DESCRIPTION
|
||||
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-yes\fR
|
||||
Skip the confirmation prompt
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
<\fIREQUEST\fR>
|
||||
The request in natural language
|
||||
+10
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
install \- Install an agent and its dependencies
|
||||
.SH SYNOPSIS
|
||||
\fBinstall\fR [\fB\-\-method\fR] [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR>
|
||||
\fBinstall\fR [\fB\-\-method\fR] [\fB\-\-force\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-\-no\-config\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR>
|
||||
.SH DESCRIPTION
|
||||
Install an agent and its dependencies
|
||||
.SH OPTIONS
|
||||
@@ -15,6 +15,15 @@ Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, b
|
||||
\fB\-\-force\fR
|
||||
Reinstall even if already installed
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to configure (default: the agent\*(Aqs provider, else settings.default_provider) (issue #90)
|
||||
.TP
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Model to configure (default: the provider\*(Aqs default model) (issue #90)
|
||||
.TP
|
||||
\fB\-\-no\-config\fR
|
||||
Skip the post\-install provider configuration (issue #91)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-providers 1 "providers "
|
||||
.SH NAME
|
||||
providers \- Manage the LLM provider registry: base URLs, models, default provider
|
||||
.SH SYNOPSIS
|
||||
\fBproviders\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
Manage the LLM provider registry: base URLs, models, default provider
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
providers\-list(1)
|
||||
List the registered providers (the default one is starred)
|
||||
.TP
|
||||
providers\-show(1)
|
||||
Show one provider in detail (base URL, models, default model)
|
||||
.TP
|
||||
providers\-add(1)
|
||||
Register a provider, or update an existing one
|
||||
.TP
|
||||
providers\-remove(1)
|
||||
Remove a provider from the registry
|
||||
.TP
|
||||
providers\-set\-token(1)
|
||||
Store the provider API token in the OS keyring (shared by every agent of this provider through the @secret cascade, issue #89)
|
||||
.TP
|
||||
providers\-token(1)
|
||||
Check whether a provider token exists (the value is never shown)
|
||||
.TP
|
||||
providers\-default(1)
|
||||
Set the default provider
|
||||
.TP
|
||||
providers\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
@@ -0,0 +1,29 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-registry 1 "registry "
|
||||
.SH NAME
|
||||
registry \- Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.SH SYNOPSIS
|
||||
\fBregistry\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
registry\-publish(1)
|
||||
Prepare a catalog + manifest (source, version, author, sha256) for publication on Gitea
|
||||
.TP
|
||||
registry\-search(1)
|
||||
Search agents across the registered sources (settings.registry.sources)
|
||||
.TP
|
||||
registry\-install(1)
|
||||
Install a catalog from the registry: manifest + checksum validation, then an explicit trust decision for unknown sources
|
||||
.TP
|
||||
registry\-list(1)
|
||||
List the registered sources
|
||||
.TP
|
||||
registry\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
+7
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
restart \- Restart an agent: stop, then start with the same options
|
||||
.SH SYNOPSIS
|
||||
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Restart an agent: stop, then start with the same options
|
||||
.SH OPTIONS
|
||||
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to use for this run (issue #92): resolved from the registry
|
||||
.TP
|
||||
\fB\-\-no\-sandbox\fR
|
||||
Skip the agent\*(Aqs sandbox profile for this run (issue #79)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Start every member of a group simultaneously (issue #57)
|
||||
.TP
|
||||
|
||||
+7
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
run \- Run the agent command directly with the given arguments (no process management)
|
||||
.SH SYNOPSIS
|
||||
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
|
||||
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
|
||||
.SH DESCRIPTION
|
||||
Run the agent command directly with the given arguments (no process management)
|
||||
.SH OPTIONS
|
||||
@@ -12,6 +12,12 @@ Run the agent command directly with the given arguments (no process management)
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
|
||||
.TP
|
||||
\fB\-\-no\-sandbox\fR
|
||||
Skip the agent\*(Aqs sandbox profile (issue #79)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
secret\-set(1)
|
||||
Store a secret for an agent
|
||||
Store a secret for an agent (or a provider, issue #89)
|
||||
.TP
|
||||
secret\-unset(1)
|
||||
Remove a secret
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-serve 1 "serve "
|
||||
.SH NAME
|
||||
serve \- Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
|
||||
.SH SYNOPSIS
|
||||
\fBserve\fR [\fB\-\-token\fR] [\fB\-\-host\fR] [\fB\-\-port\fR] [\fB\-\-rate\-limit\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-token\fR \fI<TOKEN>\fR
|
||||
Bearer token required on every request (mandatory)
|
||||
.TP
|
||||
\fB\-\-host\fR \fI<HOST>\fR
|
||||
Listen address (default 127.0.0.1)
|
||||
.TP
|
||||
\fB\-\-port\fR \fI<PORT>\fR
|
||||
Listening port (default 8080)
|
||||
.TP
|
||||
\fB\-\-rate\-limit\fR \fI<N>\fR
|
||||
Per\-IP request budget per minute (default 120)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-setup 1 "setup "
|
||||
.SH NAME
|
||||
setup \- Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
.SH SYNOPSIS
|
||||
\fBsetup\fR [\fB\-\-roles\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-roles\fR
|
||||
Only (re)generate the am\-* copilot roles for aichat
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
+7
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
start \- Start an agent (foreground by default, or detached with \-\-background)
|
||||
.SH SYNOPSIS
|
||||
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Start an agent (foreground by default, or detached with \-\-background)
|
||||
.SH OPTIONS
|
||||
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to use for this run (issue #92): resolved from the registry
|
||||
.TP
|
||||
\fB\-\-no\-sandbox\fR
|
||||
Skip the agent\*(Aqs sandbox profile for this run (issue #79)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Start every member of a group simultaneously (issue #57)
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.6.0"
|
||||
.TH am 1 "am 1.1.9"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -118,12 +118,27 @@ Schedule am commands (issue #56)
|
||||
am\-models(1)
|
||||
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.TP
|
||||
am\-registry(1)
|
||||
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.TP
|
||||
am\-catalog(1)
|
||||
Manage remote catalogs: update the official one, add external ones
|
||||
.TP
|
||||
am\-providers(1)
|
||||
Manage the LLM provider registry: base URLs, models, default provider
|
||||
.TP
|
||||
am\-suggest(1)
|
||||
Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
.TP
|
||||
am\-ask(1)
|
||||
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
|
||||
.TP
|
||||
am\-ai(1)
|
||||
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||
.TP
|
||||
am\-setup(1)
|
||||
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
.TP
|
||||
am\-start(1)
|
||||
Start an agent (foreground by default, or detached with \-\-background)
|
||||
.TP
|
||||
@@ -148,6 +163,9 @@ Real\-time monitor of the managed processes (issue #50)
|
||||
am\-web(1)
|
||||
Local read\-only web dashboard with charts (issue #54)
|
||||
.TP
|
||||
am\-serve(1)
|
||||
Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
|
||||
.TP
|
||||
am\-sync(1)
|
||||
Push the state into the configured git repository (issue #66)
|
||||
.TP
|
||||
@@ -232,4 +250,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.6.0
|
||||
v1.1.9
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am web — Cockpit sombre (mockup)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
|
||||
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
|
||||
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
|
||||
}
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
|
||||
}
|
||||
.layout { display:flex; min-height:100vh; }
|
||||
/* ---------- Sidebar ---------- */
|
||||
.side {
|
||||
width:225px; flex-shrink:0; background:var(--panel2);
|
||||
border-right:1px solid var(--border); padding:18px 12px;
|
||||
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
|
||||
}
|
||||
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
|
||||
.logo-badge {
|
||||
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
|
||||
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
|
||||
}
|
||||
.logo b { font-size:16px; letter-spacing:.5px; }
|
||||
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
|
||||
.nav-item {
|
||||
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
|
||||
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
|
||||
}
|
||||
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
|
||||
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
|
||||
.nav-item .ico { width:18px; text-align:center; }
|
||||
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
|
||||
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; }
|
||||
/* ---------- Main ---------- */
|
||||
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
|
||||
.topbar h1 { font-size:20px; font-weight:700; }
|
||||
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
|
||||
.top-actions { display:flex; align-items:center; gap:10px; }
|
||||
.badge {
|
||||
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
|
||||
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
|
||||
}
|
||||
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
|
||||
.badge.live::before { content:"● "; }
|
||||
.btn {
|
||||
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
|
||||
font-weight:700; font-size:13px; cursor:pointer;
|
||||
}
|
||||
.btn:hover { filter:brightness(1.1); }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
|
||||
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
|
||||
/* ---------- KPI cards ---------- */
|
||||
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
|
||||
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
|
||||
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
|
||||
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
|
||||
.kpi .delta { font-size:11.5px; margin-top:4px; }
|
||||
.kpi .delta.up { color:var(--ok); } .kpi .delta.down { color:var(--err); }
|
||||
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
|
||||
/* ---------- Panels ---------- */
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
|
||||
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
|
||||
/* bars */
|
||||
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
|
||||
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
|
||||
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); }
|
||||
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
/* trend svg */
|
||||
.trend-wrap { position:relative; }
|
||||
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
|
||||
/* tables */
|
||||
table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
|
||||
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
|
||||
tbody tr:hover { background:rgba(61,220,151,.05); }
|
||||
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; }
|
||||
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
|
||||
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
|
||||
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
|
||||
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
|
||||
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
|
||||
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
|
||||
.search:focus { outline:none; border-color:var(--accent); }
|
||||
/* timeline */
|
||||
.tl { list-style:none; }
|
||||
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
|
||||
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
|
||||
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
|
||||
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
|
||||
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
|
||||
.panel.hidden, .view { display:none; } .view.active { display:block; }
|
||||
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="logo">
|
||||
<div class="logo-badge">am</div>
|
||||
<div><b>agent-manager</b><small>cockpit des agents</small></div>
|
||||
</div>
|
||||
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">Vue d'ensemble</span></div>
|
||||
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">Statistiques</span></div>
|
||||
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
|
||||
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">Événements</span></div>
|
||||
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">Projets</span></div>
|
||||
<div class="side-foot">
|
||||
<span><span class="dot"></span>v0.6.0 · lang FR ·<br>127.0.0.1:7878</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 id="view-title">Vue d'ensemble</h1>
|
||||
<div class="sub" id="last-update">Dernière mise à jour : il y a 5 s</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="badge live">serveur local</span>
|
||||
<span class="badge">127.0.0.1:7878</span>
|
||||
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== OVERVIEW ===== -->
|
||||
<section class="view active" id="overview">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="emoji">🤖</span><div class="label">Agents installés</div><div class="value">9</div><div class="delta up">▲ +1 cette semaine</div></div>
|
||||
<div class="kpi"><span class="emoji">⚡</span><div class="label">En cours</div><div class="value">2</div><div class="delta up">● claude-code · codex</div></div>
|
||||
<div class="kpi"><span class="emoji">⏱</span><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 % vs semaine préc.</div></div>
|
||||
<div class="kpi"><span class="emoji">📜</span><div class="label">Événements (24 h)</div><div class="value">132</div><div class="delta down">▼ 8 % vs hier</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>Top agents — sessions (7 j)</h3>
|
||||
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
|
||||
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
|
||||
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
|
||||
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
|
||||
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
|
||||
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Activité — sessions par jour (14 j)</h3>
|
||||
<div class="trend-wrap">
|
||||
<svg viewBox="0 0 480 150" width="100%" height="150">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3ddc97" stop-opacity=".35"/><stop offset="1" stop-color="#3ddc97" stop-opacity="0"/></linearGradient></defs>
|
||||
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g)"/>
|
||||
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3ddc97" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="480" cy="34" r="4" fill="#38bdf8"/>
|
||||
</svg>
|
||||
<div class="trend-axis"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>Activité récente</h3>
|
||||
<ul class="tl">
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">claude-code</span> terminé — session 3 h 12 min</span><span class="m">il y a 2 min</span></li>
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">codex</span> démarré — projet flowdeck</span><span class="m">il y a 14 min</span></li>
|
||||
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">jcode</span> mis à jour 0.76.0 → 0.78.1</span><span class="m">il y a 1 h</span></li>
|
||||
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">aider</span> échec — exit code 1</span><span class="m">il y a 3 h</span></li>
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">gemini-cli</span> terminé — session 42 min</span><span class="m">il y a 5 h</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Sessions en cours</h3>
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Projet</th><th>Durée</th><th>Démarrée</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>agent-manager</td><td class="mono">3 h 12 min</td><td class="mono">14:26</td></tr>
|
||||
<tr><td>codex</td><td>flowdeck</td><td class="mono">14 min</td><td class="mono">17:18</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="running-empty" style="display:none">Aucune session en cours</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== STATS ===== -->
|
||||
<section class="view" id="stats">
|
||||
<div class="panel">
|
||||
<h3>Statistiques par agent — période 30 j</h3>
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
|
||||
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
|
||||
<tr><td>opencode</td><td>9</td><td class="mono">4 h 40 m</td><td><span class="pill fail">55 %</span></td><td class="mono">0,90 €</td><td class="mono">il y a 2 j</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SESSIONS ===== -->
|
||||
<section class="view" id="sessions">
|
||||
<div class="panel">
|
||||
<h3>Sessions — 7 derniers jours</h3>
|
||||
<input class="search" id="sess-search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
|
||||
<tbody id="sess-body">
|
||||
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
|
||||
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
|
||||
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
|
||||
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
|
||||
<tr><td class="mono">20260817_183011_e2f3a4</td><td>jcode</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 22 min</td><td class="mono">19:35</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="sess-empty" style="display:none">Aucune session ne correspond au filtre</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== EVENTS ===== -->
|
||||
<section class="view" id="events">
|
||||
<div class="panel">
|
||||
<h3>Journal d'événements — aujourd'hui</h3>
|
||||
<ul class="tl">
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> codex — projet flowdeck</span><span class="m">17:18</span></li>
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> claude-code — session 1 h 05 min</span><span class="m">16:40</span></li>
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> claude-code — projet agent-manager</span><span class="m">15:34</span></li>
|
||||
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">run</span> aider — exit code 1 (fichier manquant)</span><span class="m">14:33</span></li>
|
||||
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">update</span> jcode 0.76.0 → 0.78.1</span><span class="m">13:05</span></li>
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> gemini-cli — session 42 min</span><span class="m">12:12</span></li>
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> gemini-cli — projet imago</span><span class="m">11:30</span></li>
|
||||
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">install</span> opencode 0.14.2 — méthode binary</span><span class="m">10:02</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PROJECTS ===== -->
|
||||
<section class="view" id="projects">
|
||||
<div class="kpis" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="kpi"><div class="label">Projets suivis</div><div class="value">4</div><div class="delta up">▲ 1 nouveau cette semaine</div></div>
|
||||
<div class="kpi"><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 %</div></div>
|
||||
<div class="kpi"><div class="label">Temps total</div><div class="value">96 h</div><div class="delta up">▲ +8 h vs semaine préc.</div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Projets</h3>
|
||||
<table>
|
||||
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
|
||||
document.querySelectorAll('.nav-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
const v = el.dataset.view;
|
||||
document.getElementById(v).classList.add('active');
|
||||
document.getElementById('view-title').textContent = titles[v];
|
||||
});
|
||||
});
|
||||
function fakeRefresh(){
|
||||
const t = new Date();
|
||||
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + t.toLocaleTimeString('fr-FR');
|
||||
}
|
||||
function filterSessions(q){
|
||||
q = q.toLowerCase();
|
||||
let shown = 0;
|
||||
document.querySelectorAll('#sess-body tr').forEach(tr => {
|
||||
const ok = tr.textContent.toLowerCase().includes(q);
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
if (ok) shown++;
|
||||
});
|
||||
document.getElementById('sess-empty').style.display = shown ? 'none' : 'block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am web — Admin clair (mockup)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#f6f7f9; --panel:#ffffff; --border:#e3e7ec; --fg:#1c2430; --muted:#64748b;
|
||||
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --err:#dc2626; --purple:#7c3aed;
|
||||
}
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
|
||||
}
|
||||
/* header bar */
|
||||
.head {
|
||||
background:var(--panel); border-bottom:1px solid var(--border);
|
||||
padding:0 28px; height:56px; display:flex; align-items:center; gap:24px; position:sticky; top:0; z-index:5;
|
||||
}
|
||||
.head .brand { font-weight:800; font-size:16px; display:flex; align-items:center; gap:9px; }
|
||||
.brand .sq { width:26px; height:26px; border-radius:7px; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; }
|
||||
.tabs { display:flex; gap:4px; height:100%; }
|
||||
.tab { display:flex; align-items:center; padding:0 16px; color:var(--muted); cursor:pointer; font-size:13.5px; font-weight:500; border-bottom:2px solid transparent; }
|
||||
.tab:hover { color:var(--fg); }
|
||||
.tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||||
.head .spacer { flex:1; }
|
||||
.hbadge { font-size:11.5px; color:var(--muted); background:var(--bg); border:1px solid var(--border); padding:4px 10px; border-radius:99px; font-family:ui-monospace,Consolas,monospace; }
|
||||
.hbadge.ok { color:var(--ok); }
|
||||
.btn { background:var(--accent); color:#fff; border:none; border-radius:7px; padding:7px 14px; font-weight:600; font-size:13px; cursor:pointer; }
|
||||
.btn:hover { background:#1d4ed8; }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
|
||||
.btn.ghost:hover { color:var(--fg); }
|
||||
.wrap { max-width:1150px; margin:26px auto; padding:0 28px; }
|
||||
.page-title { font-size:19px; font-weight:700; margin-bottom:4px; }
|
||||
.page-sub { color:var(--muted); font-size:12.5px; margin-bottom:20px; }
|
||||
.cards { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
|
||||
.card { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:15px 17px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
|
||||
.card .l { color:var(--muted); font-size:11.5px; text-transform:uppercase; letter-spacing:.5px; }
|
||||
.card .v { font-size:26px; font-weight:800; margin-top:5px; color:#111827; }
|
||||
.card .d { font-size:11.5px; margin-top:3px; } .card .d.up{color:var(--ok);} .card .d.down{color:var(--err);}
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px 18px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
|
||||
.panel h3 { font-size:12.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--muted); margin-bottom:14px; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.4px; padding:7px 10px; border-bottom:1px solid var(--border); background:var(--bg); }
|
||||
td { padding:9px 10px; border-bottom:1px solid var(--border); }
|
||||
tbody tr:hover { background:#fafbfc; }
|
||||
.pill { font-size:10.5px; padding:2px 9px; border-radius:99px; font-weight:600; }
|
||||
.pill.ok { background:#dcfce7; color:var(--ok); } .pill.run { background:#dbeafe; color:#1d4ed8; }
|
||||
.pill.fail { background:#fee2e2; color:var(--err); } .pill.mut { background:#ede9fe; color:var(--purple); }
|
||||
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
|
||||
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
|
||||
.bar-row .name { width:110px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.bar-row .track { flex:1; height:12px; background:#eef1f5; border-radius:6px; overflow:hidden; }
|
||||
.bar-row .fill { height:100%; background:var(--accent); border-radius:6px; }
|
||||
.bar-row .val { width:32px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
.search { width:100%; border:1px solid var(--border); border-radius:7px; padding:8px 12px; font-size:13px; margin-bottom:10px; background:#fff; }
|
||||
.search:focus { outline:none; border-color:var(--accent); }
|
||||
.view { display:none; } .view.active { display:block; }
|
||||
@media (max-width:900px){ .cards{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="head">
|
||||
<div class="brand"><span class="sq">am</span> agent-manager</div>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-view="overview">Vue d'ensemble</div>
|
||||
<div class="tab" data-view="stats">Statistiques</div>
|
||||
<div class="tab" data-view="sessions">Sessions</div>
|
||||
<div class="tab" data-view="events">Événements</div>
|
||||
<div class="tab" data-view="projects">Projets</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="hbadge ok">● local</span>
|
||||
<span class="hbadge">127.0.0.1:7878 · v0.6.0</span>
|
||||
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-title" id="view-title">Vue d'ensemble</div>
|
||||
<div class="page-sub" id="last-update">Dernière mise à jour : il y a 5 s · lang FR</div>
|
||||
|
||||
<section class="view active" id="overview">
|
||||
<div class="cards">
|
||||
<div class="card"><div class="l">Agents installés</div><div class="v">9</div><div class="d up">▲ +1 cette semaine</div></div>
|
||||
<div class="card"><div class="l">En cours</div><div class="v">2</div><div class="d up">● claude-code · codex</div></div>
|
||||
<div class="card"><div class="l">Sessions (7 j)</div><div class="v">47</div><div class="d up">▲ +12 %</div></div>
|
||||
<div class="card"><div class="l">Événements (24 h)</div><div class="v">132</div><div class="d down">▼ 8 % vs hier</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>Top agents — sessions (7 j)</h3>
|
||||
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
|
||||
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
|
||||
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
|
||||
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
|
||||
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
|
||||
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Activité — sessions par jour (14 j)</h3>
|
||||
<svg viewBox="0 0 480 150" width="100%" height="150">
|
||||
<defs><linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2563eb" stop-opacity=".2"/><stop offset="1" stop-color="#2563eb" stop-opacity="0"/></linearGradient></defs>
|
||||
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g2)"/>
|
||||
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10.5px;font-family:ui-monospace,monospace;margin-top:4px"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Sessions — 7 derniers jours</h3>
|
||||
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
|
||||
<tbody id="sess-body-ov">
|
||||
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
|
||||
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
|
||||
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
|
||||
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="stats">
|
||||
<div class="panel">
|
||||
<h3>Statistiques par agent — période 30 j</h3>
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
|
||||
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="sessions">
|
||||
<div class="panel">
|
||||
<h3>Sessions — 7 derniers jours</h3>
|
||||
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
|
||||
<tbody id="sess-body">
|
||||
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
|
||||
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="events">
|
||||
<div class="panel">
|
||||
<h3>Journal d'événements — aujourd'hui</h3>
|
||||
<table>
|
||||
<thead><tr><th>Heure</th><th>Type</th><th>Agent</th><th>Détail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">17:18</td><td><span class="pill run">start</span></td><td>codex</td><td>projet flowdeck</td></tr>
|
||||
<tr><td class="mono">16:40</td><td><span class="pill ok">stop</span></td><td>claude-code</td><td>session 1 h 05 min</td></tr>
|
||||
<tr><td class="mono">15:34</td><td><span class="pill run">start</span></td><td>claude-code</td><td>projet agent-manager</td></tr>
|
||||
<tr><td class="mono">14:33</td><td><span class="pill fail">run</span></td><td>aider</td><td>exit code 1</td></tr>
|
||||
<tr><td class="mono">13:05</td><td><span class="pill mut">update</span></td><td>jcode</td><td>0.76.0 → 0.78.1</td></tr>
|
||||
<tr><td class="mono">10:02</td><td><span class="pill mut">install</span></td><td>opencode</td><td>0.14.2 — méthode binary</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="projects">
|
||||
<div class="panel">
|
||||
<h3>Projets</h3>
|
||||
<table>
|
||||
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
|
||||
document.querySelectorAll('.tab').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(el.dataset.view).classList.add('active');
|
||||
document.getElementById('view-title').textContent = titles[el.dataset.view];
|
||||
});
|
||||
});
|
||||
function fakeRefresh(){
|
||||
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + new Date().toLocaleTimeString('fr-FR') + ' · lang FR';
|
||||
}
|
||||
function filterSessions(q){
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('#sess-body tr, #sess-body-ov tr').forEach(tr => {
|
||||
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am web — Terminal dense (mockup)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#0d1117; --panel:#10161f; --border:#212a36; --fg:#c9d4e0; --muted:#6e7b8a;
|
||||
--accent:#3fb950; --accent2:#58a6ff; --ok:#3fb950; --warn:#d29922; --err:#f85149; --purple:#bc8cff;
|
||||
}
|
||||
body {
|
||||
font-family:ui-monospace,SFMono-Regular,Consolas,"Cascadia Mono",Menlo,monospace;
|
||||
background:var(--bg); color:var(--fg); font-size:12.5px; line-height:1.5;
|
||||
}
|
||||
.top {
|
||||
border-bottom:1px solid var(--border); padding:9px 16px; display:flex; align-items:center; gap:16px;
|
||||
background:var(--panel); position:sticky; top:0; z-index:5;
|
||||
}
|
||||
.top .prompt { color:var(--accent); font-weight:700; }
|
||||
.top .brand { color:var(--fg); }
|
||||
.tabs { display:flex; gap:2px; }
|
||||
.tab { padding:3px 10px; cursor:pointer; color:var(--muted); border:1px solid transparent; border-radius:5px; }
|
||||
.tab:hover { color:var(--fg); }
|
||||
.tab.active { color:var(--accent); background:rgba(63,185,80,.08); border-color:rgba(63,185,80,.3); }
|
||||
.top .spacer { flex:1; }
|
||||
.stat { color:var(--muted); font-size:11.5px; }
|
||||
.stat b { color:var(--ok); font-weight:400; }
|
||||
.btn { background:transparent; border:1px solid var(--border); color:var(--fg); border-radius:5px; padding:3px 10px; font-family:inherit; font-size:12px; cursor:pointer; }
|
||||
.btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.wrap { max-width:1180px; margin:0 auto; padding:14px 16px 40px; }
|
||||
/* overview row */
|
||||
.kv { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:14px; }
|
||||
.kv-item { border:1px solid var(--border); background:var(--panel); border-radius:6px; padding:9px 12px; }
|
||||
.kv-item .k { color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; }
|
||||
.kv-item .v { font-size:21px; font-weight:700; color:var(--fg); margin-top:2px; }
|
||||
.kv-item .v small { color:var(--muted); font-size:11px; font-weight:400; }
|
||||
.kv-item.running .v { color:var(--accent); }
|
||||
.kv-item.running .v::before { content:"● "; }
|
||||
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:14px; }
|
||||
.box { border:1px solid var(--border); background:var(--panel); border-radius:6px; overflow:hidden; }
|
||||
.box .h { padding:7px 12px; border-bottom:1px solid var(--border); color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; display:flex; justify-content:space-between; }
|
||||
.box .h .n { color:var(--accent); text-transform:none; letter-spacing:0; }
|
||||
.box .b { padding:10px 12px; }
|
||||
/* bars */
|
||||
.br { display:grid; grid-template-columns:95px 1fr 30px; gap:8px; align-items:center; margin-bottom:6px; }
|
||||
.br .nm { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.br .tr { height:10px; background:var(--bg); border-radius:3px; overflow:hidden; }
|
||||
.br .fl { height:100%; background:var(--accent); opacity:.85; }
|
||||
.br .vl { text-align:right; color:var(--muted); }
|
||||
/* tables */
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th { text-align:left; color:var(--muted); font-weight:400; font-size:10.5px; text-transform:uppercase; letter-spacing:.6px; padding:5px 10px; border-bottom:1px solid var(--border); }
|
||||
td { padding:5px 10px; border-bottom:1px solid rgba(33,42,54,.6); white-space:nowrap; }
|
||||
td.wrap { white-space:normal; }
|
||||
tbody tr:hover { background:rgba(63,185,80,.05); }
|
||||
.st { font-size:11px; } .st::before { content:"● "; }
|
||||
.st.ok { color:var(--ok); } .st.run { color:var(--accent2); } .st.fail { color:var(--err); } .st.mut { color:var(--purple); }
|
||||
.dim { color:var(--muted); }
|
||||
.view { display:none; } .view.active { display:block; }
|
||||
@media (max-width:900px){ .row{grid-template-columns:1fr;} .kv{grid-template-columns:repeat(2,1fr);} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<span class="prompt">$</span><span class="brand">am web</span>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-view="overview">overview</div>
|
||||
<div class="tab" data-view="stats">stats</div>
|
||||
<div class="tab" data-view="sessions">sessions</div>
|
||||
<div class="tab" data-view="events">events</div>
|
||||
<div class="tab" data-view="projects">projects</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="stat">v0.6.0 · <b>●</b> local · 127.0.0.1:7878</span>
|
||||
<button class="btn" onclick="fakeRefresh()">↻ refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<section class="view active" id="overview">
|
||||
<div class="kv">
|
||||
<div class="kv-item"><div class="k">agents installés</div><div class="v">9</div></div>
|
||||
<div class="kv-item running"><div class="k">en cours</div><div class="v">2</div></div>
|
||||
<div class="kv-item"><div class="k">sessions 7j</div><div class="v">47 <small>▲12%</small></div></div>
|
||||
<div class="kv-item"><div class="k">events 24h</div><div class="v">132 <small>▼8%</small></div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="box">
|
||||
<div class="h">top agents · sessions 7j</div>
|
||||
<div class="b">
|
||||
<div class="br"><span class="nm">claude-code</span><div class="tr"><div class="fl" style="width:100%"></div></div><span class="vl">18</span></div>
|
||||
<div class="br"><span class="nm">aider</span><div class="tr"><div class="fl" style="width:50%"></div></div><span class="vl">9</span></div>
|
||||
<div class="br"><span class="nm">codex</span><div class="tr"><div class="fl" style="width:44%"></div></div><span class="vl">8</span></div>
|
||||
<div class="br"><span class="nm">gemini-cli</span><div class="tr"><div class="fl" style="width:28%"></div></div><span class="vl">5</span></div>
|
||||
<div class="br"><span class="nm">jcode</span><div class="tr"><div class="fl" style="width:22%"></div></div><span class="vl">4</span></div>
|
||||
<div class="br"><span class="nm">opencode</span><div class="tr"><div class="fl" style="width:17%"></div></div><span class="vl">3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="h">activité 14j <span class="n">sessions/jour</span></div>
|
||||
<div class="b">
|
||||
<svg viewBox="0 0 480 150" width="100%" height="140">
|
||||
<defs><linearGradient id="g3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3fb950" stop-opacity=".3"/><stop offset="1" stop-color="#3fb950" stop-opacity="0"/></linearGradient></defs>
|
||||
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g3)"/>
|
||||
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3fb950" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10px"><span>04/08</span><span>11/08</span><span>18/08</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="h">sessions en cours</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>durée</th><th>depuis</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td>3h12m</td><td class="dim">14:26</td></tr>
|
||||
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td>14m</td><td class="dim">17:18</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="stats">
|
||||
<div class="box">
|
||||
<div class="h">stats par agent · 30j</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>agent</th><th>sessions</th><th>durée</th><th>succès</th><th>coût</th><th>dernière act.</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>54</td><td>38h12m</td><td><span class="st ok">94%</span></td><td class="dim">12,40 €</td><td class="dim">2 min</td></tr>
|
||||
<tr><td>aider</td><td>31</td><td>21h48m</td><td><span class="st ok">87%</span></td><td class="dim">4,10 €</td><td class="dim">3 h</td></tr>
|
||||
<tr><td>codex</td><td>27</td><td>19h05m</td><td><span class="st ok">89%</span></td><td class="dim">8,75 €</td><td class="dim">14 min</td></tr>
|
||||
<tr><td>gemini-cli</td><td>14</td><td>6h33m</td><td><span class="st ok">79%</span></td><td class="dim">0 € local</td><td class="dim">5 h</td></tr>
|
||||
<tr><td>jcode</td><td>12</td><td>5h02m</td><td><span class="st run">67%</span></td><td class="dim">1,20 €</td><td class="dim">hier</td></tr>
|
||||
<tr><td>opencode</td><td>9</td><td>4h40m</td><td><span class="st fail">55%</span></td><td class="dim">0,90 €</td><td class="dim">2 j</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="sessions">
|
||||
<div class="box">
|
||||
<div class="h">sessions · 7j</div>
|
||||
<div class="b" style="padding:8px 12px 0">
|
||||
<input id="sess-search" placeholder="$ grep sessions…" style="width:100%;background:var(--bg);border:1px solid var(--border);color:var(--fg);font-family:inherit;font-size:12px;padding:6px 10px;border-radius:4px;margin-bottom:6px" oninput="filterSessions(this.value)">
|
||||
</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>statut</th><th>durée</th><th>fin</th></tr></thead>
|
||||
<tbody id="sess-body">
|
||||
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="st run">running</span></td><td>3h12m</td><td class="dim">—</td></tr>
|
||||
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="st run">running</span></td><td>14m</td><td class="dim">—</td></tr>
|
||||
<tr><td class="dim">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>1h05m</td><td class="dim">16:40</td></tr>
|
||||
<tr><td class="dim">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="st fail">failed</span></td><td>12m</td><td class="dim">14:33</td></tr>
|
||||
<tr><td class="dim">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="st ok">done</span></td><td>42m</td><td class="dim">12:12</td></tr>
|
||||
<tr><td class="dim">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>2h47m</td><td class="dim">23:50</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="events">
|
||||
<div class="box">
|
||||
<div class="h">events · aujourd'hui</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>heure</th><th>type</th><th>agent</th><th class="wrap">détail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="dim">17:18</td><td><span class="st run">start</span></td><td>codex</td><td class="wrap">projet flowdeck</td></tr>
|
||||
<tr><td class="dim">16:40</td><td><span class="st ok">stop</span></td><td>claude-code</td><td class="wrap">session 1h05m</td></tr>
|
||||
<tr><td class="dim">15:34</td><td><span class="st run">start</span></td><td>claude-code</td><td class="wrap">projet agent-manager</td></tr>
|
||||
<tr><td class="dim">14:33</td><td><span class="st fail">run</span></td><td>aider</td><td class="wrap">exit code 1 — fichier manquant</td></tr>
|
||||
<tr><td class="dim">13:05</td><td><span class="st mut">update</span></td><td>jcode</td><td class="wrap">0.76.0 → 0.78.1</td></tr>
|
||||
<tr><td class="dim">10:02</td><td><span class="st mut">install</span></td><td>opencode</td><td class="wrap">0.14.2 — méthode binary</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="projects">
|
||||
<div class="box">
|
||||
<div class="h">projects</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>projet</th><th>agents</th><th>sessions 7j</th><th>temps</th><th>dernière act.</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>agent-manager <span class="dim">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="dim">41h</td><td class="dim">2 min</td></tr>
|
||||
<tr><td>flowdeck <span class="dim">· Python</span></td><td>codex · aider</td><td>11</td><td class="dim">19h</td><td class="dim">14 min</td></tr>
|
||||
<tr><td>obsigate <span class="dim">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="dim">22h</td><td class="dim">3 h</td></tr>
|
||||
<tr><td>imago <span class="dim">· Python</span></td><td>gemini-cli</td><td>4</td><td class="dim">14h</td><td class="dim">5 h</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tab').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(el.dataset.view).classList.add('active');
|
||||
});
|
||||
});
|
||||
function fakeRefresh(){}
|
||||
function filterSessions(q){
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('#sess-body tr').forEach(tr => {
|
||||
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,622 @@
|
||||
//! Post-install agent configuration (issue #91): the `config:` block of an
|
||||
//! agent definition wires the resolved provider/model into the agent —
|
||||
//! the environment variables it expects (env_map) and the config files to
|
||||
//! write or softly edit (files).
|
||||
//!
|
||||
//! Pure functions + per-format tests (probe.rs pattern). Security rule: a
|
||||
//! token is never written in clear — the api_key slot stays the `@secret`
|
||||
//! reference (issue #89), resolved from the OS keyring at start/run time.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{AgentDef, Config};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// The four format families covering ~every agent (spec #91).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileFormat {
|
||||
Toml,
|
||||
Yaml,
|
||||
Json,
|
||||
KeyValue,
|
||||
}
|
||||
|
||||
impl FileFormat {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Toml => "toml",
|
||||
FileFormat::Yaml => "yaml",
|
||||
FileFormat::Json => "json",
|
||||
FileFormat::KeyValue => "key=value",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the format family from the file extension.
|
||||
pub fn format_of(path: &Path) -> Option<FileFormat> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"toml" => Some(FileFormat::Toml),
|
||||
"yaml" | "yml" => Some(FileFormat::Yaml),
|
||||
"json" => Some(FileFormat::Json),
|
||||
"conf" | "ini" | "env" | "properties" | "cfg" => Some(FileFormat::KeyValue),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider preferred by an agent: `provider:` of the definition, then the
|
||||
/// `config.provider_default` of its config block (issue #91).
|
||||
pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
|
||||
agent
|
||||
.provider
|
||||
.as_deref()
|
||||
.or(agent.config.as_ref().and_then(|c| c.provider_default.as_deref()))
|
||||
}
|
||||
|
||||
/// Merge the resolved `config.env_map` of an agent into its default env
|
||||
/// (issue #91): api_key -> `@secret` (resolved by resolve_env_secrets),
|
||||
/// model/base_url/provider -> values of the resolved provider. Explicit
|
||||
/// values already present in agent.env are never overwritten.
|
||||
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
|
||||
apply_env_map_resolved(env, agent, config, None, None);
|
||||
}
|
||||
|
||||
/// Same as `apply_env_map` but with the provider and model fixed by the
|
||||
/// caller (issue #92: `am run/start --provider/--model`): the flags win
|
||||
/// over the agent declaration and the settings default.
|
||||
pub fn apply_env_map_resolved(
|
||||
env: &mut BTreeMap<String, String>,
|
||||
agent: &AgentDef,
|
||||
config: &Config,
|
||||
provider_override: Option<&str>,
|
||||
model_override: Option<&str>,
|
||||
) {
|
||||
let Some(cfg) = &agent.config else {
|
||||
return;
|
||||
};
|
||||
if cfg.env_map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(pname) = provider_override
|
||||
.filter(|p| !p.is_empty())
|
||||
.or_else(|| provider_pref(agent))
|
||||
.or_else(|| crate::providers::default_name(config))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(def) = crate::providers::get(config, pname) else {
|
||||
return;
|
||||
};
|
||||
let model = model_override
|
||||
.filter(|m| !m.is_empty())
|
||||
.or(agent.model.as_deref())
|
||||
.or(def.default_model.as_deref());
|
||||
for (slot, var) in &cfg.env_map {
|
||||
let value = match slot.as_str() {
|
||||
"api_key" => "@secret".to_string(),
|
||||
"model" => {
|
||||
let Some(m) = model else { continue };
|
||||
m.to_string()
|
||||
}
|
||||
"base_url" => def.base_url.clone(),
|
||||
"provider" => pname.to_string(),
|
||||
_ => continue, // unknown slots are ignored
|
||||
};
|
||||
env.entry(var.clone()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Substitute the {model}, {provider} and {base_url} placeholders of a
|
||||
/// setup hint with the resolved provider configuration (v1.1.4). When no
|
||||
/// provider is resolved, the hint is returned unchanged.
|
||||
pub fn render_hint(
|
||||
hint: &str,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
config: &Config,
|
||||
) -> String {
|
||||
let Some((pname, model)) = resolved else {
|
||||
return hint.to_string();
|
||||
};
|
||||
let base_url = crate::providers::get(config, pname)
|
||||
.map(|d| d.base_url.clone())
|
||||
.unwrap_or_default();
|
||||
hint.replace("{provider}", pname)
|
||||
.replace(
|
||||
"{model}",
|
||||
model.as_deref().unwrap_or("{model}"),
|
||||
)
|
||||
.replace("{base_url}", &base_url)
|
||||
}
|
||||
|
||||
/// Print the manual setup commands of an agent (agents that manage their
|
||||
/// own configuration, e.g. picoclaw onboard/model/auth). No-op when the
|
||||
/// agent declares none (v1.1.4).
|
||||
fn print_hints(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
) {
|
||||
if agent.setup_hints.is_empty() {
|
||||
return;
|
||||
}
|
||||
app.log
|
||||
.success(crate::i18n::tr("Configuration manuelle requise — à exécuter :"));
|
||||
for hint in &agent.setup_hints {
|
||||
app.log.info(&format!(" {}", render_hint(hint, resolved, &app.config)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the agent configuration files after a successful install
|
||||
/// (issue #91). The caller skips this on `--no-config`, on non-configurable
|
||||
/// agents, and in dry-run (never reached). Never writes a token in clear:
|
||||
/// the api_key slot is written as the `@secret` reference.
|
||||
pub fn apply_post_install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
) -> Result<()> {
|
||||
let Some(cfg) = &agent.config else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"agent sans adaptateur de config — voir sa doc",
|
||||
));
|
||||
print_hints(app, agent, resolved);
|
||||
return Ok(());
|
||||
};
|
||||
if cfg.files.is_empty() {
|
||||
if cfg.env_map.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"agent sans adaptateur de config — voir sa doc",
|
||||
));
|
||||
} else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"config par variables d'env — injectée au démarrage",
|
||||
));
|
||||
}
|
||||
print_hints(app, agent, resolved);
|
||||
return Ok(());
|
||||
}
|
||||
let Some((pname, model)) = resolved else {
|
||||
app.log.warn(crate::i18n::tr(
|
||||
"aucun provider résolu — fichiers de config non écrits",
|
||||
));
|
||||
print_hints(app, agent, resolved);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(def) = crate::providers::get(&app.config, pname) else {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"provider '{}' introuvable — fichiers de config non écrits",
|
||||
pname
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
for f in &cfg.files {
|
||||
let path = crate::config::expand_path(&f.path);
|
||||
let Some(fmt) = format_of(&path) else {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"format inconnu pour {} — adaptateurs: toml, yaml, json, key=value",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let mut updates: Vec<(String, String)> = Vec::new();
|
||||
for key in &f.keys {
|
||||
let value = match key.as_str() {
|
||||
"api_key" => "@secret".to_string(),
|
||||
"model" => model.clone().unwrap_or_default(),
|
||||
"base_url" => def.base_url.clone(),
|
||||
"provider" => pname.clone(),
|
||||
other => {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"clé '{}' inconnue dans config.files de {}",
|
||||
other,
|
||||
agent.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
updates.push((key.clone(), value));
|
||||
}
|
||||
write_soft(&path, fmt, &updates)?;
|
||||
app.log
|
||||
.success(&crate::tr_fmt!("config écrite: {}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft edit of a config file: existing unknown keys are preserved, known
|
||||
/// keys are updated, missing keys are appended. Creates parent dirs.
|
||||
pub fn write_soft(path: &Path, format: FileFormat, updates: &[(String, String)]) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
let text = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let out = match format {
|
||||
FileFormat::Toml => edit_toml(&text, updates)?,
|
||||
FileFormat::Yaml => edit_yaml(&text, updates)?,
|
||||
FileFormat::Json => edit_json(&text, updates)?,
|
||||
FileFormat::KeyValue => edit_key_value(&text, updates),
|
||||
};
|
||||
std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TOML soft edit. Note: toml::Value does not retain comments on
|
||||
/// serialization — unknown KEYS are preserved, comments are dropped.
|
||||
fn edit_toml(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: toml::Value = if text.trim().is_empty() {
|
||||
toml::Value::Table(Default::default())
|
||||
} else {
|
||||
text.parse::<toml::Value>()
|
||||
.map_err(|e| anyhow!("invalid TOML: {e}"))?
|
||||
};
|
||||
let table = root
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| anyhow!("TOML root is not a table"))?;
|
||||
for (k, v) in updates {
|
||||
table.insert(k.clone(), toml::Value::String(v.clone()));
|
||||
}
|
||||
Ok(root.to_string())
|
||||
}
|
||||
|
||||
fn edit_yaml(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: serde_yaml::Value = if text.trim().is_empty() {
|
||||
serde_yaml::Value::Mapping(Default::default())
|
||||
} else {
|
||||
serde_yaml::from_str(text).map_err(|e| anyhow!("invalid YAML: {e}"))?
|
||||
};
|
||||
let map = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("YAML root is not a mapping"))?;
|
||||
for (k, v) in updates {
|
||||
map.insert(
|
||||
serde_yaml::Value::String(k.clone()),
|
||||
serde_yaml::Value::String(v.clone()),
|
||||
);
|
||||
}
|
||||
serde_yaml::to_string(&root).map_err(|e| anyhow!("cannot serialize YAML: {e}"))
|
||||
}
|
||||
|
||||
fn edit_json(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: serde_json::Value = if text.trim().is_empty() {
|
||||
serde_json::Value::Object(Default::default())
|
||||
} else {
|
||||
serde_json::from_str(text).map_err(|e| anyhow!("invalid JSON: {e}"))?
|
||||
};
|
||||
let obj = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("JSON root is not an object"))?;
|
||||
for (k, v) in updates {
|
||||
obj.insert(k.clone(), serde_json::Value::String(v.clone()));
|
||||
}
|
||||
serde_json::to_string_pretty(&root).map_err(|e| anyhow!("cannot serialize JSON: {e}"))
|
||||
}
|
||||
|
||||
fn edit_key_value(text: &str, updates: &[(String, String)]) -> String {
|
||||
let mut lines: Vec<String> = text.lines().map(String::from).collect();
|
||||
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for (i, line) in lines.iter_mut().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.contains('=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = trimmed.split('=').next().unwrap_or("").trim();
|
||||
// Case-insensitive match (API_KEY vs api_key in .env files); the
|
||||
// existing line keeps its casing.
|
||||
if let Some((k, v)) = updates.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
|
||||
*line = format!("{key}={v}");
|
||||
seen.insert(k, i);
|
||||
}
|
||||
}
|
||||
for (k, v) in updates {
|
||||
if !seen.contains_key(k.as_str()) {
|
||||
lines.push(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use crate::config::{AgentConfig, Config};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn updates() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("model".to_string(), "gpt-5.2".to_string()),
|
||||
("base_url".to_string(), "https://api.openai.com/v1".to_string()),
|
||||
("api_key".to_string(), "@secret".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn tmp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join(name);
|
||||
if !content.is_empty() {
|
||||
std::fs::write(&p, content).unwrap();
|
||||
}
|
||||
(dir, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_detection_covers_the_four_families() {
|
||||
assert_eq!(format_of(Path::new("a.toml")), Some(FileFormat::Toml));
|
||||
assert_eq!(format_of(Path::new("a.yaml")), Some(FileFormat::Yaml));
|
||||
assert_eq!(format_of(Path::new("a.yml")), Some(FileFormat::Yaml));
|
||||
assert_eq!(format_of(Path::new("a.json")), Some(FileFormat::Json));
|
||||
assert_eq!(format_of(Path::new("a.conf")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.env")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.ini")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.md")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.toml", "# existing\nmodel = \"old\"\ntemperature = 0.7\n");
|
||||
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
|
||||
assert!(
|
||||
out.contains("base_url = \"https://api.openai.com/v1\""),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("api_key = \"@secret\""), "{out}");
|
||||
assert!(out.contains("temperature = 0.7"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_creates_the_file_when_missing() {
|
||||
let (_d, p) = tmp("new.toml", "");
|
||||
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
|
||||
assert!(out.contains("api_key = \"@secret\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.yaml", "model: old\nverbose: true\n");
|
||||
write_soft(&p, FileFormat::Yaml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model: gpt-5.2"), "{out}");
|
||||
assert!(out.contains("base_url: https://api.openai.com/v1"), "{out}");
|
||||
assert!(out.contains("verbose: true"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.json", "{\"model\": \"old\", \"verbose\": true}");
|
||||
write_soft(&p, FileFormat::Json, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("\"model\": \"gpt-5.2\""), "{out}");
|
||||
assert!(out.contains("\"api_key\": \"@secret\""), "{out}");
|
||||
assert!(out.contains("\"verbose\": true"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_round_trip_keeps_unknown_lines() {
|
||||
let (_d, p) = tmp(
|
||||
"c.conf",
|
||||
"# header\nMODEL=old\nOTHER=keep\n",
|
||||
);
|
||||
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("MODEL=gpt-5.2"), "{out}");
|
||||
assert!(out.contains("OTHER=keep"), "unknown line lost: {out}");
|
||||
assert!(out.contains("# header"), "comment lost: {out}");
|
||||
assert!(out.contains("api_key=@secret"), "{out}");
|
||||
// No duplicate MODEL line.
|
||||
assert_eq!(out.matches("MODEL=").count(), 1, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_appends_missing_keys() {
|
||||
let (_d, p) = tmp("empty.env", "");
|
||||
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model=gpt-5.2"), "{out}");
|
||||
assert!(out.contains("base_url=https://api.openai.com/v1"), "{out}");
|
||||
assert!(out.contains("api_key=@secret"), "{out}");
|
||||
}
|
||||
|
||||
fn agent_with_config(config: AgentConfig, provider: Option<String>) -> AgentDef {
|
||||
AgentDef {
|
||||
name: "x".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
category: None,
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("x".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: None,
|
||||
model_arg: None,
|
||||
provider,
|
||||
model: None,
|
||||
config: Some(config),
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_map_wires_the_resolved_provider() {
|
||||
let mut c: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
|
||||
env_map.insert("model".to_string(), "MY_MODEL".to_string());
|
||||
env_map.insert("base_url".to_string(), "MY_URL".to_string());
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map,
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
apply_env_map(&mut env, &agent, &c);
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
// Explicit agent.env values win.
|
||||
let mut env2 = BTreeMap::new();
|
||||
env2.insert("MY_MODEL".to_string(), "explicit".to_string());
|
||||
apply_env_map(&mut env2, &agent, &c);
|
||||
assert_eq!(env2.get("MY_MODEL").unwrap(), "explicit");
|
||||
// Unset default provider + no provider_default: no-op.
|
||||
c.settings.default_provider = None;
|
||||
let mut env3 = BTreeMap::new();
|
||||
apply_env_map(&mut env3, &agent, &c);
|
||||
assert!(env3.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_post_install_without_config_block_is_a_noop() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app = crate::app::App::from_cli(cli).unwrap();
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
// No config block at all → Ok, nothing written.
|
||||
let mut bare = agent.clone();
|
||||
bare.config = None;
|
||||
assert!(apply_post_install(&app, &bare, &None).is_ok());
|
||||
// Config block but no files → Ok, nothing written.
|
||||
assert!(apply_post_install(&app, &agent, &None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_pref_prefers_the_declared_provider() {
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: Some("openai".to_string()),
|
||||
},
|
||||
Some("anthropic".to_string()),
|
||||
);
|
||||
assert_eq!(provider_pref(&agent), Some("anthropic"));
|
||||
let agent2 = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: Some("openai".to_string()),
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(provider_pref(&agent2), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_hint_substitutes_resolved_values() {
|
||||
let c: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n default_provider: openrouter\n providers:\n openrouter:\n base_url: https://openrouter.ai/api/v1\n default_model: deepseek/deepseek-chat-v3-0324\n models: [deepseek/deepseek-chat-v3-0324]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = Some(("openrouter".to_string(), Some("deepseek/deepseek-chat-v3-0324".to_string())));
|
||||
assert_eq!(
|
||||
render_hint("picoclaw model {model}", &resolved, &c),
|
||||
"picoclaw model deepseek/deepseek-chat-v3-0324"
|
||||
);
|
||||
assert_eq!(render_hint("picoclaw onboard", &resolved, &c), "picoclaw onboard");
|
||||
assert_eq!(
|
||||
render_hint("cli --provider {provider} --url {base_url}", &resolved, &c),
|
||||
"cli --provider openrouter --url https://openrouter.ai/api/v1"
|
||||
);
|
||||
// No resolved provider: the hint is left untouched.
|
||||
assert_eq!(
|
||||
render_hint("picoclaw model {model}", &None, &c),
|
||||
"picoclaw model {model}"
|
||||
);
|
||||
// Resolved provider without a model: {model} stays visible.
|
||||
let no_model = Some(("openrouter".to_string(), None));
|
||||
assert_eq!(
|
||||
render_hint("picoclaw model {model}", &no_model, &c),
|
||||
"picoclaw model {model}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_post_install_with_setup_hints_is_ok() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app = crate::app::App::from_cli(cli).unwrap();
|
||||
let mut agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
agent.config = None;
|
||||
agent.setup_hints = vec![
|
||||
"picoclaw onboard".to_string(),
|
||||
"picoclaw model {model}".to_string(),
|
||||
];
|
||||
// No provider resolved: hints printed with placeholders, no crash.
|
||||
assert!(apply_post_install(&app, &agent, &None).is_ok());
|
||||
assert!(apply_post_install(&app, &agent, &Some(("openrouter".to_string(), None))).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,8 @@ impl App {
|
||||
if let Err(e) = crate::events::append(&self.events_dir(), event) {
|
||||
self.log.verbose(&format!("cannot write event journal: {e:#}"));
|
||||
}
|
||||
// Issue #76: aggregated anonymous counters (no-op while disabled).
|
||||
crate::telemetry::maybe_record(self, event);
|
||||
crate::plugins::dispatch(self, event);
|
||||
}
|
||||
|
||||
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
//! am ask — natural language → am commands (issue #78).
|
||||
//!
|
||||
//! A local rule-based translator (offline, zero dependency, always works),
|
||||
//! an optional LLM refinement through the provider registry (settings.ask),
|
||||
//! and a translation cache for offline reuse. Every execution is confirmed
|
||||
//! before it runs, and no journal data is ever sent to the provider — only
|
||||
//! the user's own query.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::commands::execute_command;
|
||||
use crate::config::AskSettings;
|
||||
use crate::secrets::SecretStore;
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// System prompt of the refinement call: the model must answer with one am
|
||||
/// command per line, nothing else.
|
||||
const SYSTEM_PROMPT: &str = "You translate a user request into commands of the 'am' CLI \
|
||||
(agent-manager). Reply with ONE 'am' command per line — no text, no backticks, no \
|
||||
explanation. Known commands: install, uninstall, update, start, stop, restart, run, \
|
||||
list, status, ps, logs, history, stats, top, report, doctor, config, providers, \
|
||||
secret, suggest, search, info, open, watch, sync, web, monitor, schedule, ask. \
|
||||
Examples: «installe claude et lance-le» -> install claude-code\nstart claude-code ; \
|
||||
«qui tourne ?» -> ps.";
|
||||
|
||||
const CACHE_MAX_ENTRIES: usize = 200;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rules (offline, bilingual FR/EN)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Known agent nicknames resolved before scanning the catalog.
|
||||
const NICKNAMES: &[(&str, &str)] = &[
|
||||
("claude", "claude-code"),
|
||||
("claude code", "claude-code"),
|
||||
("codex", "codex"),
|
||||
("agentty", "agentty"),
|
||||
("dsh", "deepseek-harness"),
|
||||
("deepseek", "deepseek-harness"),
|
||||
("opencode", "opencode"),
|
||||
("aider", "aider"),
|
||||
("gptme", "gptme"),
|
||||
("goose", "goose"),
|
||||
("openclaw", "openclaw"),
|
||||
("jcode", "jcode"),
|
||||
];
|
||||
|
||||
fn agent_of(part: &str, app: &App, prev: Option<&str>) -> Option<String> {
|
||||
// Pronouns reuse the previous agent of the sentence.
|
||||
let p = part.trim().to_lowercase();
|
||||
let pronouns = [
|
||||
"-le", "-la", "-les", "-l", "le", "la", "les", "l'", "it", "him", "her", "them", "celui-ci",
|
||||
"celui la", "celui-là", "the agent", "l'agent",
|
||||
];
|
||||
if pronouns.iter().any(|pr| p.contains(pr)) {
|
||||
if let Some(prev) = prev {
|
||||
return Some(prev.to_string());
|
||||
}
|
||||
}
|
||||
// Nicknames first (claude before scanning for "claude-code").
|
||||
for (nick, target) in NICKNAMES {
|
||||
if p.contains(nick) {
|
||||
return Some(target.to_string());
|
||||
}
|
||||
}
|
||||
// Exact catalog names, longest first so "claude-code" wins over "claude".
|
||||
let mut names: Vec<&str> = app.config.agents.iter().map(|a| a.name.as_str()).collect();
|
||||
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
|
||||
for name in names {
|
||||
if p.contains(name) {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn translate_part(part: &str, app: &App, prev: &mut Option<String>) -> Option<String> {
|
||||
let p = part.trim().to_lowercase();
|
||||
let agent = agent_of(part, app, prev.as_deref()).or_else(|| prev.clone());
|
||||
let needs = |cmd: &str| -> Option<String> {
|
||||
let a = agent.clone().or_else(|| agent_of(part, app, prev.as_deref()));
|
||||
match a {
|
||||
Some(a) => Some(format!("{cmd} {a}")),
|
||||
None => Some(format!("{cmd} --all")), // "tout mettre à jour" etc.
|
||||
}
|
||||
};
|
||||
let cmd = if p.contains("desinstalle") || p.contains("désinstalle")
|
||||
|| p.contains("uninstall") || p.contains("supprime")
|
||||
{
|
||||
needs("uninstall")
|
||||
} else if p.contains("mets a jour") || p.contains("mets à jour") || p.contains("maj ")
|
||||
|| p.contains("mise a jour") || p.contains("update") || p.contains("upgrade")
|
||||
{
|
||||
if agent.is_some() {
|
||||
needs("update")
|
||||
} else {
|
||||
Some("update --all".to_string())
|
||||
}
|
||||
} else if p.contains("installe") || p.contains("install") || p.contains("setup")
|
||||
|| p.contains("instal")
|
||||
{
|
||||
needs("install")
|
||||
} else if p.contains("arrete") || p.contains("arrête") || p.contains("stop")
|
||||
|| p.contains("kill") || p.contains("tue") || p.contains("eteins") || p.contains("éteins")
|
||||
{
|
||||
needs("stop")
|
||||
} else if p.contains("relance") || p.contains("redemarre") || p.contains("redémarre")
|
||||
|| p.contains("restart") || p.contains("reboot")
|
||||
{
|
||||
needs("restart")
|
||||
} else if p.contains("lance") || p.contains("start") || p.contains("demarre")
|
||||
|| p.contains("démarre") || p.contains("ouvre") || p.contains("execute") || p.contains("exécute")
|
||||
{
|
||||
match agent {
|
||||
Some(a) => Some(format!("start {a}")),
|
||||
None => Some("start".to_string()),
|
||||
}
|
||||
} else if p.contains("historique") || p.contains("history") {
|
||||
Some("history".to_string())
|
||||
} else if p.contains("statistique") || p.contains("statistique") || p.contains("stats") {
|
||||
Some("stats".to_string())
|
||||
} else if p.contains("qui tourne") || p.contains("processus") || p.contains("ps ") {
|
||||
Some("ps".to_string())
|
||||
} else if p.contains("sante") || p.contains("santé") || p.contains("doctor")
|
||||
|| p.contains("diagnostic") || p.contains("checkup") || p.contains("verifie tout")
|
||||
{
|
||||
Some("doctor".to_string())
|
||||
} else if p.contains("provider") || p.contains("fournisseur") {
|
||||
Some("providers list".to_string())
|
||||
} else if p.contains("secret") {
|
||||
Some("secret list".to_string())
|
||||
} else if p.contains("config") {
|
||||
Some("config show".to_string())
|
||||
} else if p.contains("tout") && p.contains("liste") {
|
||||
Some("list --all".to_string())
|
||||
} else if p.contains("liste") || p.contains("list") || p.contains("lister")
|
||||
|| p.contains("catalogue") || p.contains("agents")
|
||||
{
|
||||
Some("list".to_string())
|
||||
} else if p.contains("journal") || p.contains("log") {
|
||||
Some("logs".to_string())
|
||||
} else if p.contains("aide") || p.contains("help") {
|
||||
Some("help".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(c) = &cmd {
|
||||
// Keep the agent context for the following pronouns.
|
||||
if let Some(a) = agent_of(part, app, prev.as_deref()) {
|
||||
*prev = Some(a);
|
||||
}
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Rule-based translation of a full query into am commands.
|
||||
fn rules_translate(query: &str, app: &App) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut prev: Option<String> = None;
|
||||
// Sentence separators (FR + EN): each chunk becomes one command.
|
||||
for chunk in query.split(|c| matches!(c, ',' | ';' | '.')) {
|
||||
for part in chunk.splitn(2, |c| c == ':' || c == ';') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Split on conjunctions, keeping the order.
|
||||
let mut pieces: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
for word in part.split_whitespace() {
|
||||
if matches!(
|
||||
word.to_lowercase().as_str(),
|
||||
"et" | "puis" | "and" | "then" | "ensuite" | "alors" | "&" | "+"
|
||||
) {
|
||||
if !current.trim().is_empty() {
|
||||
pieces.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
} else {
|
||||
current.push_str(word);
|
||||
current.push(' ');
|
||||
}
|
||||
}
|
||||
if !current.trim().is_empty() {
|
||||
pieces.push(current.trim().to_string());
|
||||
}
|
||||
for piece in pieces {
|
||||
if let Some(cmd) = translate_part(&piece, app, &mut prev) {
|
||||
if !out.contains(&cmd) {
|
||||
out.push(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional LLM refinement (issue #78) — via the provider registry (#88)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// ask is enabled by default (local rules work out of the box, issue #78);
|
||||
/// an explicit `ask.enabled: false` turns the whole command off.
|
||||
fn ask_enabled(app: &App) -> bool {
|
||||
app.config
|
||||
.settings
|
||||
.ask
|
||||
.as_ref()
|
||||
.map(|a| a.enabled)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The optional LLM refinement settings: (provider, model), both None when
|
||||
/// no provider is configured (rules-only mode).
|
||||
fn ask_settings(app: &App) -> Option<(Option<&str>, Option<&str>)> {
|
||||
if !ask_enabled(app) {
|
||||
return None;
|
||||
}
|
||||
let a = app.config.settings.ask.as_ref();
|
||||
Some((
|
||||
a.and_then(|x| x.provider.as_deref()).filter(|p| !p.is_empty()),
|
||||
a.and_then(|x| x.model.as_deref()).filter(|m| !m.is_empty()),
|
||||
))
|
||||
}
|
||||
|
||||
fn is_known_command(first_word: &str) -> bool {
|
||||
matches!(
|
||||
first_word,
|
||||
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
|
||||
| "list" | "status" | "ps" | "logs" | "history" | "stats" | "top"
|
||||
| "report" | "doctor" | "config" | "providers" | "secret" | "suggest"
|
||||
| "search" | "info" | "open" | "watch" | "sync" | "web" | "monitor"
|
||||
| "schedule" | "ask" | "lab" | "playbook"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask the provider registry to translate the query. Returns None when no
|
||||
/// provider is configured (rules-only mode). Only the user's query is sent —
|
||||
/// never journal data.
|
||||
fn llm_translate(app: &App, query: &str) -> Result<Option<Vec<String>>> {
|
||||
let Some((provider, model)) = ask_settings(app) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let provider_name = provider.or_else(|| crate::providers::default_name(&app.config));
|
||||
let Some(provider_name) = provider_name else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(def) = crate::providers::get(&app.config, provider_name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let model = model.or(def.default_model.as_deref());
|
||||
let Some(model) = model else {
|
||||
return Ok(None);
|
||||
};
|
||||
let token = crate::secrets::store()
|
||||
.get(&crate::secrets::key_for_provider_token(provider_name))
|
||||
.ok()
|
||||
.flatten();
|
||||
let endpoint = format!("{}/chat/completions", def.base_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"max_tokens": 256,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
});
|
||||
let mut req = ureq::post(&endpoint)
|
||||
.set("Content-Type", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(20));
|
||||
if let Some(t) = &token {
|
||||
req = req.set("Authorization", &format!("Bearer {t}"));
|
||||
}
|
||||
let resp = req
|
||||
.send_string(&body.to_string())
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
let json: serde_json::Value = serde_json::from_reader(resp.into_reader())?;
|
||||
let content = json
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(|c| c.as_str())
|
||||
.ok_or_else(|| anyhow!("réponse du fournisseur illisible"))?;
|
||||
let commands: Vec<String> = content
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_start_matches("am ").trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter(|l| {
|
||||
let first = l.split_whitespace().next().unwrap_or("");
|
||||
is_known_command(first)
|
||||
})
|
||||
.collect();
|
||||
if commands.is_empty() {
|
||||
anyhow::bail!("le fournisseur n'a produit aucune commande valide");
|
||||
}
|
||||
Ok(Some(commands))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache (offline reuse, issue #78)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct AskCache {
|
||||
entries: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
fn cache_path(app: &App) -> PathBuf {
|
||||
app.paths
|
||||
.state_file
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join("ask_cache.json")
|
||||
}
|
||||
|
||||
fn cache_load(app: &App) -> AskCache {
|
||||
std::fs::read_to_string(cache_path(app))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn cache_save(app: &App, cache: &AskCache) {
|
||||
if let Ok(json) = serde_json::to_string_pretty(cache) {
|
||||
let _ = std::fs::write(cache_path(app), json);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Translate a natural-language query into am commands (rules first, then
|
||||
/// the optional LLM, both cached for offline reuse).
|
||||
pub fn translate(app: &App, query: &str) -> Result<Vec<String>> {
|
||||
let key = query.trim().to_lowercase();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("usage: am ask <demande en langage naturel>");
|
||||
}
|
||||
// 1. Cache hit — works offline, even after a previous LLM translation.
|
||||
let mut cache = cache_load(app);
|
||||
if let Some(hit) = cache.entries.get(&key) {
|
||||
return Ok(hit.clone());
|
||||
}
|
||||
// 2. Local rules (zero dependency).
|
||||
let rules = rules_translate(query, app);
|
||||
if !rules.is_empty() {
|
||||
cache.entries.insert(key.clone(), rules.clone());
|
||||
cache_save(app, &cache);
|
||||
return Ok(rules);
|
||||
}
|
||||
// 3. Optional LLM refinement through the provider registry. Failures
|
||||
// (no token, provider unreachable, malformed answer) degrade to the
|
||||
// rules-only result instead of blocking the command.
|
||||
match llm_translate(app, query) {
|
||||
Ok(Some(llm)) => {
|
||||
cache.entries.insert(key.clone(), llm.clone());
|
||||
cache_save(app, &cache);
|
||||
return Ok(llm);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
app.log
|
||||
.verbose(&format!("ask LLM refinement skipped: {e:#}"));
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// am ask entry point: translate, show, confirm, execute.
|
||||
pub fn run(app: &App, query: &str, yes: bool) -> Result<i32> {
|
||||
if !ask_enabled(app) {
|
||||
app.log.error(crate::i18n::tr(
|
||||
"am ask est désactivé — settings.ask.enabled: true pour l'activer",
|
||||
));
|
||||
return Ok(1);
|
||||
}
|
||||
let commands = translate(app, query)?;
|
||||
if commands.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
for c in &commands {
|
||||
println!(" am {c}");
|
||||
}
|
||||
if !yes && !app.confirm(&crate::i18n::tr("exécuter ces commandes ?"))? {
|
||||
app.log.info(crate::i18n::tr("annulé"));
|
||||
return Ok(0);
|
||||
}
|
||||
for c in &commands {
|
||||
let tokens = shell_words::split(c)?;
|
||||
let mut argv = vec!["am".to_string()];
|
||||
argv.extend(tokens);
|
||||
let cli = crate::cli::Cli::parse_from(argv);
|
||||
let Some(cmd) = cli.command else {
|
||||
continue;
|
||||
};
|
||||
let code = execute_command(app, &cmd)?;
|
||||
if code != 0 {
|
||||
return Ok(code);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(ask_yaml: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{ask_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.state_file = dir.join("state.json");
|
||||
app.paths = p;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_install_and_launch_claude() {
|
||||
let app = test_app("");
|
||||
let cmds = translate(&app, "installe claude et lance-le").unwrap();
|
||||
assert_eq!(cmds, vec!["install claude-code", "start claude-code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_cover_common_requests() {
|
||||
let app = test_app("");
|
||||
assert_eq!(translate(&app, "liste les agents").unwrap(), vec!["list"]);
|
||||
assert_eq!(translate(&app, "arrête codex").unwrap(), vec!["stop codex"]);
|
||||
assert_eq!(
|
||||
translate(&app, "mets à jour tout").unwrap(),
|
||||
vec!["update --all"]
|
||||
);
|
||||
assert_eq!(translate(&app, "qui tourne ?").unwrap(), vec!["ps"]);
|
||||
assert_eq!(
|
||||
translate(&app, "installe deepseek-harness").unwrap(),
|
||||
vec!["install deepseek-harness"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_unknown_query_is_empty() {
|
||||
let app = test_app("");
|
||||
assert!(translate(&app, "quelle est la météo demain ?").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_reuses_translations_offline() {
|
||||
let app = test_app("");
|
||||
let cmds = translate(&app, "installe claude et lance-le").unwrap();
|
||||
assert_eq!(cmds.len(), 2);
|
||||
// Second pass: served from the cache file (rules are deterministic,
|
||||
// but the cache also carries previous LLM translations offline).
|
||||
let cache = cache_load(&app);
|
||||
assert!(cache.entries.contains_key("installe claude et lance-le"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_refinement_uses_the_registry_and_never_sends_journal_data() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let app = test_app(&format!(
|
||||
" default_provider: openai\n ask:\n enabled: true\n provider: openai\n providers:\n openai:\n base_url: http://127.0.0.1:{port}\n default_model: gpt-5.2\n"
|
||||
));
|
||||
// Store a fake token in the keyring namespace of the provider.
|
||||
let store = crate::secrets::store();
|
||||
let key = crate::secrets::key_for_provider_token("openai");
|
||||
store.set(&key, "sk-test-token").unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut req = server
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.expect("server recv failed")
|
||||
.expect("the ask call must reach the provider");
|
||||
assert_eq!(req.url(), "/chat/completions");
|
||||
let mut body = String::new();
|
||||
req.as_reader().read_to_string(&mut body).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(json["model"], "gpt-5.2");
|
||||
assert_eq!(json["temperature"], 0);
|
||||
let user_msg = json["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["role"] == "user")
|
||||
.unwrap()["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let auth = req.headers().iter().find(|h| h.field.equiv("Authorization")).map(|h| h.value.as_str().to_string());
|
||||
let _ = req.respond(tiny_http::Response::from_string(
|
||||
r#"{"choices":[{"message":{"content":"install claude-code\nstart claude-code"}}]}"#,
|
||||
));
|
||||
(user_msg, auth)
|
||||
});
|
||||
// The rules do NOT cover this query — the LLM refinement is reached.
|
||||
let cmds = translate(&app, "donne-moi un rapport de la semaine").unwrap();
|
||||
assert_eq!(cmds, vec!["install claude-code", "start claude-code"]);
|
||||
let (user_msg, auth) = handle.join().unwrap();
|
||||
// The provider receives the query and the keyring token — nothing else.
|
||||
assert_eq!(user_msg, "donne-moi un rapport de la semaine");
|
||||
assert_eq!(auth.as_deref(), Some("Bearer sk-test-token"));
|
||||
store.remove(&key).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_failure_degrades_to_rules_only() {
|
||||
// No provider configured explicitly: the embedded default (anthropic)
|
||||
// would 401 without a token — translate() must degrade silently to
|
||||
// the rules-only result instead of blocking.
|
||||
let app = test_app("");
|
||||
let cmds = translate(&app, "une requête hors règles").unwrap();
|
||||
assert!(cmds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_ask_is_refused() {
|
||||
let app = test_app(" ask:\n enabled: false\n");
|
||||
// run() reports the refusal with exit code 1 (not an error).
|
||||
assert_eq!(run(&app, "installe claude", true).unwrap(), 1);
|
||||
}
|
||||
}
|
||||
+208
-5
@@ -189,15 +189,40 @@ pub enum Command {
|
||||
Schedule(ScheduleCmd),
|
||||
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
Models(ModelsArgs),
|
||||
/// Community registry (issue #77): publish, search and install agent
|
||||
/// catalogs hosted on Gitea.
|
||||
Registry(RegistryArgs),
|
||||
/// Manage remote catalogs: update the official one, add external ones
|
||||
#[command(subcommand)]
|
||||
Catalog(CatalogCmd),
|
||||
/// Manage the LLM provider registry: base URLs, models, default provider
|
||||
Providers(ProviderArgs),
|
||||
/// Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
Suggest {
|
||||
/// Free-form request ("un agent pour du Python")
|
||||
#[arg(value_name = "QUERY", num_args = 1.., required = true)]
|
||||
words: Vec<String>,
|
||||
},
|
||||
/// Ask a natural-language request and get the matching am command(s)
|
||||
/// (issue #78): local rules first, optional LLM refinement, confirmation
|
||||
/// before execution.
|
||||
Ask {
|
||||
/// The request in natural language
|
||||
#[arg(value_name = "REQUEST", num_args = 1.., required = true)]
|
||||
query: Vec<String>,
|
||||
/// Skip the confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||
#[command(alias = "shell")]
|
||||
Ai(AiArgs),
|
||||
/// Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
Setup {
|
||||
/// Only (re)generate the am-* copilot roles for aichat
|
||||
#[arg(long)]
|
||||
roles: bool,
|
||||
},
|
||||
/// Start an agent (foreground by default, or detached with --background)
|
||||
Start(StartArgs),
|
||||
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
|
||||
@@ -275,6 +300,8 @@ pub enum Command {
|
||||
},
|
||||
/// Local read-only web dashboard with charts (issue #54)
|
||||
Web(WebArgs),
|
||||
/// Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
|
||||
Serve(ServeArgs),
|
||||
/// Push the state into the configured git repository (issue #66)
|
||||
Sync {
|
||||
/// Commit message (default: "am sync — state update")
|
||||
@@ -357,6 +384,15 @@ pub enum Command {
|
||||
/// Reinstall even if already installed
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Provider to configure (default: the agent's provider, else settings.default_provider) (issue #90)
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
/// Model to configure (default: the provider's default model) (issue #90)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Skip the post-install provider configuration (issue #91)
|
||||
#[arg(long)]
|
||||
no_config: bool,
|
||||
},
|
||||
/// Uninstall an agent (managed or external) and clean its files
|
||||
Uninstall {
|
||||
@@ -484,6 +520,12 @@ pub enum Command {
|
||||
/// Local model to use for this run (validated against the local runtimes)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
/// Skip the agent's sandbox profile (issue #79)
|
||||
#[arg(long)]
|
||||
no_sandbox: bool,
|
||||
/// Run the agent inside a container (issue #58)
|
||||
#[arg(long)]
|
||||
container: bool,
|
||||
@@ -555,6 +597,12 @@ pub struct StartArgs {
|
||||
/// Local model to use for this run (validated against the local runtimes)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
pub model: Option<String>,
|
||||
/// Provider to use for this run (issue #92): resolved from the registry
|
||||
#[arg(long)]
|
||||
pub provider: Option<String>,
|
||||
/// Skip the agent's sandbox profile for this run (issue #79)
|
||||
#[arg(long)]
|
||||
pub no_sandbox: bool,
|
||||
/// Start every member of a group simultaneously (issue #57)
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub parallel: bool,
|
||||
@@ -600,6 +648,48 @@ pub struct LabArgs {
|
||||
pub list: bool,
|
||||
}
|
||||
|
||||
/// Arguments of the registry command (issue #77).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct RegistryArgs {
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<RegistryCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum RegistryCmd {
|
||||
/// Prepare a catalog + manifest (source, version, author, sha256) for
|
||||
/// publication on Gitea
|
||||
Publish {
|
||||
/// Path of the catalog YAML file
|
||||
catalog: PathBuf,
|
||||
/// Public URL of the catalog once pushed (recorded in the manifest)
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
/// Author name recorded in the manifest
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
/// Catalog version (default 1.0.0)
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
},
|
||||
/// Search agents across the registered sources (settings.registry.sources)
|
||||
Search {
|
||||
/// Free-form keyword (agent name, description or tag)
|
||||
query: String,
|
||||
},
|
||||
/// Install a catalog from the registry: manifest + checksum validation,
|
||||
/// then an explicit trust decision for unknown sources
|
||||
Install {
|
||||
/// Catalog URL
|
||||
url: String,
|
||||
/// Skip the trust confirmation
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// List the registered sources
|
||||
List,
|
||||
}
|
||||
|
||||
/// Arguments of the plugins command (issue #75).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct PluginsArgs {
|
||||
@@ -619,6 +709,56 @@ pub struct WebArgs {
|
||||
pub no_open: bool,
|
||||
}
|
||||
|
||||
/// Arguments of am ai (issues #96 #97): natural language → shell action
|
||||
/// via AIChat. The global flags --dry-run and --yes/-y apply as well.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct AiArgs {
|
||||
/// The request in natural language
|
||||
#[arg(value_name = "PROMPT", num_args = 1.., required = true)]
|
||||
pub prompt: Vec<String>,
|
||||
/// Execute the generated shell command (after the safety policy)
|
||||
#[arg(short = 'e', long, action = ArgAction::SetTrue)]
|
||||
pub exec: bool,
|
||||
/// Files or directories passed as context (repeatable; default: the
|
||||
/// current directory)
|
||||
#[arg(short = 'f', long = "files", value_name = "PATH", action = ArgAction::Append)]
|
||||
pub files: Vec<std::path::PathBuf>,
|
||||
/// Provider of the registry used by aichat (env vars + model)
|
||||
#[arg(long, value_name = "ID")]
|
||||
pub provider: Option<String>,
|
||||
/// Force a model (aichat --model)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
pub model: Option<String>,
|
||||
/// Use one of the generated copilot roles (am-copilot, am-operator,
|
||||
/// am-dev, am-do, am-analyst, am-orchestrator — issue v1.1.0 F3)
|
||||
#[arg(long, value_name = "ROLE")]
|
||||
pub role: Option<String>,
|
||||
/// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is
|
||||
/// handled by clap directly).
|
||||
#[arg(skip)]
|
||||
pub yes: bool,
|
||||
/// REPL-only: --dry-run given on the REPL line.
|
||||
#[arg(skip)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
/// Arguments of am serve (issue #80).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct ServeArgs {
|
||||
/// Bearer token required on every request (mandatory)
|
||||
#[arg(long, value_name = "TOKEN")]
|
||||
pub token: Option<String>,
|
||||
/// Listen address (default 127.0.0.1)
|
||||
#[arg(long, value_name = "HOST")]
|
||||
pub host: Option<String>,
|
||||
/// Listening port (default 8080)
|
||||
#[arg(long, value_name = "PORT")]
|
||||
pub port: Option<u16>,
|
||||
/// Per-IP request budget per minute (default 120)
|
||||
#[arg(long, value_name = "N")]
|
||||
pub rate_limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Remote catalog subcommands (issues #60 #67).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum CatalogCmd {
|
||||
@@ -634,6 +774,63 @@ pub enum CatalogCmd {
|
||||
List,
|
||||
}
|
||||
|
||||
/// LLM provider registry subcommands (issue #88). The subcommand is
|
||||
/// optional: `am providers` alone lists the registry.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct ProviderArgs {
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<ProvidersCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ProvidersCmd {
|
||||
/// List the registered providers (the default one is starred)
|
||||
List,
|
||||
/// Show one provider in detail (base URL, models, default model)
|
||||
Show {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Register a provider, or update an existing one
|
||||
Add {
|
||||
/// Provider name (lowercase slug, e.g. openai)
|
||||
name: String,
|
||||
/// Base URL of the provider API
|
||||
#[arg(long, value_name = "URL", required = true)]
|
||||
base_url: String,
|
||||
/// Model used by default for this provider
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Comma-separated model list
|
||||
#[arg(long, value_name = "MODELS")]
|
||||
models: Option<String>,
|
||||
},
|
||||
/// Remove a provider from the registry
|
||||
Remove {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Store the provider API token in the OS keyring (shared by every
|
||||
/// agent of this provider through the @secret cascade, issue #89)
|
||||
SetToken {
|
||||
/// Provider name
|
||||
name: String,
|
||||
/// Token value (never logged)
|
||||
#[arg(long, value_name = "VALUE")]
|
||||
value: String,
|
||||
},
|
||||
/// Check whether a provider token exists (the value is never shown)
|
||||
Token {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Set the default provider
|
||||
Default {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Environment profile subcommands (issue #40).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ProfileCmd {
|
||||
@@ -696,13 +893,16 @@ pub enum ScheduleCmd {
|
||||
}
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum SecretCmd {
|
||||
/// Store a secret for an agent
|
||||
/// Store a secret for an agent (or a provider, issue #89)
|
||||
Set {
|
||||
/// Secret name (the environment variable name)
|
||||
name: String,
|
||||
/// Agent it belongs to
|
||||
/// Agent it belongs to (required unless --provider is given)
|
||||
#[arg(long, value_name = "AGENT")]
|
||||
agent: String,
|
||||
agent: Option<String>,
|
||||
/// Provider it belongs to (shared by every agent of this provider)
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
/// Secret value (prefer --value over shell history; never logged)
|
||||
#[arg(long, value_name = "VALUE")]
|
||||
value: String,
|
||||
@@ -711,9 +911,12 @@ pub enum SecretCmd {
|
||||
Unset {
|
||||
/// Secret name
|
||||
name: String,
|
||||
/// Agent it belongs to
|
||||
/// Agent it belongs to (required unless --provider is given)
|
||||
#[arg(long, value_name = "AGENT")]
|
||||
agent: String,
|
||||
agent: Option<String>,
|
||||
/// Provider it belongs to
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
},
|
||||
/// List stored secret names (values are never shown)
|
||||
List,
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! am ai — langage naturel → action shell (issues #96 #97).
|
||||
//!
|
||||
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
||||
//! context files (default: the current directory). Exec mode generates the
|
||||
//! shell command through aichat (`--code` — never executed by aichat),
|
||||
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
|
||||
//! default) and only executes after confirmation when required.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::AiArgs;
|
||||
use crate::commands::{require_agent, resolve_exec};
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::shell_ai::{Decision, SafetyMode};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
||||
// Onboarding hint (v1.1.0 F1) — non-blocking: the AI commands propose
|
||||
// `am setup` when no provider is configured, then continue anyway.
|
||||
if crate::setup::needs_setup(app) {
|
||||
app.log.info(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"am n'est pas configuré — lancez am setup (provider + token)",
|
||||
));
|
||||
}
|
||||
|
||||
// F2 (v1.1.0): aichat must be INSTALLED (not only in the catalog) to
|
||||
// power am ai. Offer the installation when missing.
|
||||
if !aichat_installed(app) {
|
||||
let ask_install = !app.cli.yes
|
||||
&& app.confirm(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"aichat est manquant. Installer ?",
|
||||
))?;
|
||||
if app.cli.yes || ask_install {
|
||||
app.log.info(crate::i18n::tr_in(app.lang(), "Installation d'aichat…"));
|
||||
crate::commands::install_cmd::run(app, "aichat", None, false, None, None, false)?;
|
||||
} else {
|
||||
bail!(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"installez aichat: am install aichat — puis réessayez"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let prompt = args.prompt.join(" ");
|
||||
// Context files: explicit --files wins, else the current directory
|
||||
// (issue #96: `aichat -f . "<prompt>"`). The default directory is
|
||||
// walked by am to skip noise/vendored/binary files — aichat itself
|
||||
// filters nothing and fails on non-UTF-8 content (e.g. .git/index).
|
||||
let files: Vec<String> = if args.files.is_empty() {
|
||||
context_files(std::path::Path::new("."))
|
||||
} else {
|
||||
args.files
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// REPL per-line flags combine with the CLI globals.
|
||||
let yes = app.cli.yes || args.yes;
|
||||
let dry_run = app.cli.dry_run || args.dry_run;
|
||||
|
||||
let agent = require_agent(app, "aichat")?;
|
||||
|
||||
if !args.exec {
|
||||
return chat_mode(app, agent, &prompt, &files, args, dry_run);
|
||||
}
|
||||
exec_mode(app, &prompt, &files, args, yes, dry_run)
|
||||
}
|
||||
|
||||
/// True when the aichat binary is available (installed or on PATH).
|
||||
/// Uses a direct PATH lookup — the probe cache would mask a shim added
|
||||
/// mid-test and is meant for agent inventories, not runtime checks.
|
||||
fn aichat_installed(app: &App) -> bool {
|
||||
app.state.get("aichat").ok().flatten().is_some()
|
||||
|| which::which("aichat").is_ok()
|
||||
}
|
||||
|
||||
/// Walk `dir` and collect text files usable as aichat context (the
|
||||
/// default `--files` when the flag is absent). aichat filters nothing
|
||||
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
|
||||
/// - skipped directories: `.git`, vendored/build noise (`target`,
|
||||
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
|
||||
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
|
||||
/// Then the survivors are sorted and taken while a total budget of
|
||||
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
|
||||
/// at 40 files. Empty when nothing qualifies (aichat is called without
|
||||
/// `-f`).
|
||||
fn context_files(dir: &std::path::Path) -> Vec<String> {
|
||||
const MAX_FILES: usize = 40;
|
||||
const MAX_SIZE: u64 = 64 * 1024;
|
||||
const TOTAL_BUDGET: u64 = 128 * 1024;
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
|
||||
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
|
||||
".cargo", "obj", "out", ".svn", ".hg",
|
||||
];
|
||||
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if path.is_dir() {
|
||||
if SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
walk(&path, out);
|
||||
} else {
|
||||
if name.starts_with(".env") {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.len() > MAX_SIZE {
|
||||
continue;
|
||||
}
|
||||
// UTF-8 sniff on the first bytes — aichat rejects binaries.
|
||||
let Ok(mut f) = std::fs::File::open(&path) else { continue };
|
||||
use std::io::Read;
|
||||
let mut head = Vec::new();
|
||||
if f.take(8192).read_to_end(&mut head).is_err()
|
||||
|| std::str::from_utf8(&head).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push((path.display().to_string(), meta.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
walk(dir, &mut found);
|
||||
found.sort();
|
||||
let mut files = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
for (p, len) in found {
|
||||
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
|
||||
break;
|
||||
}
|
||||
total += len;
|
||||
files.push(p);
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// Build the aichat `--agent` arguments for the requested role. Without a
|
||||
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
|
||||
/// definition is missing the roles are regenerated on the fly, and a
|
||||
/// generation failure degrades gracefully (no role). An explicit unknown
|
||||
/// role is an error.
|
||||
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
||||
let role = role.unwrap_or("am-copilot");
|
||||
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
|
||||
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
|
||||
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
|
||||
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
|
||||
let present = || def.exists() || legacy.exists();
|
||||
if present() {
|
||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||
}
|
||||
if role == "am-copilot" {
|
||||
if let Err(e) = crate::roles::generate_all(app) {
|
||||
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
||||
}
|
||||
if present() {
|
||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||
}
|
||||
app.log.warn(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"rôle 'am-copilot' introuvable — lancez am setup --roles",
|
||||
));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
bail!(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
|
||||
)
|
||||
.replace("{}", role));
|
||||
}
|
||||
|
||||
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
|
||||
/// the provider/model environment applied when requested.
|
||||
fn chat_mode(
|
||||
app: &App,
|
||||
agent: &crate::config::AgentDef,
|
||||
prompt: &str,
|
||||
files: &[String],
|
||||
args: &AiArgs,
|
||||
dry_run: bool,
|
||||
) -> Result<i32> {
|
||||
let mut extra: Vec<String> = Vec::new();
|
||||
extra.extend(role_args(app, args.role.as_deref())?);
|
||||
for f in files {
|
||||
extra.push("-f".to_string());
|
||||
extra.push(f.clone());
|
||||
}
|
||||
extra.push(prompt.to_string());
|
||||
let (p_args, p_env) =
|
||||
crate::shell_ai::provider_env(app, args.provider.as_deref(), args.model.as_deref())?;
|
||||
extra.extend(p_args);
|
||||
if dry_run {
|
||||
app.log.dry(&format!(
|
||||
"would run aichat {} (conversationnel)",
|
||||
extra.join(" ")
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let exec = resolve_exec(app, agent, &extra, &p_env)?;
|
||||
app.log.verbose(&format!(
|
||||
"shell-ai: {} {}",
|
||||
exec.program,
|
||||
exec.args.join(" ")
|
||||
));
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
let status = Command::new(&prog)
|
||||
.args(&full_args)
|
||||
.envs(&exec.env)
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::ShellAi)
|
||||
.with_agent("aichat".to_string())
|
||||
.with_args(vec!["mode=chat".to_string(), "executed=true".to_string()]),
|
||||
);
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
/// Exec mode: generate the command (aichat --dry-run), classify it, apply
|
||||
/// the safety policy, then execute through the shell when allowed.
|
||||
fn exec_mode(
|
||||
app: &App,
|
||||
prompt: &str,
|
||||
files: &[String],
|
||||
args: &AiArgs,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> Result<i32> {
|
||||
if dry_run {
|
||||
app.log.dry(&format!(
|
||||
"would generate & classify via aichat (exec) — commande non exécutée"
|
||||
));
|
||||
}
|
||||
// Exec mode asks for the raw command: default to the am-do role
|
||||
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
|
||||
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
|
||||
let cmd = crate::shell_ai::generate(
|
||||
app,
|
||||
prompt,
|
||||
files,
|
||||
args.provider.as_deref(),
|
||||
args.model.as_deref(),
|
||||
&role,
|
||||
)?;
|
||||
let settings = app
|
||||
.config
|
||||
.settings
|
||||
.shell_ai
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns);
|
||||
let certainty = crate::shell_ai::estimate_certainty(&cmd, risk);
|
||||
let mode = settings.safety_mode();
|
||||
|
||||
// Show the generated command and its classification (issue #97 UX).
|
||||
println!("🔒 Commande générée : {}", cmd);
|
||||
println!(" Risk : {}", risk.as_str());
|
||||
println!(" Certainty: {}%", certainty);
|
||||
println!(
|
||||
" Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)",
|
||||
mode.as_str()
|
||||
);
|
||||
|
||||
let decision = crate::shell_ai::decide(mode, risk, dry_run, yes);
|
||||
let lang = app.lang();
|
||||
let mut executed = false;
|
||||
match decision {
|
||||
Decision::Abort => {
|
||||
let msg = if dry_run {
|
||||
crate::i18n::tr_in(lang, "dry-run : commande non exécutée")
|
||||
} else {
|
||||
crate::i18n::tr_in(
|
||||
lang,
|
||||
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes",
|
||||
)
|
||||
};
|
||||
app.log.info(msg);
|
||||
}
|
||||
Decision::Ask => {
|
||||
let ok = app.confirm(crate::i18n::tr_in(lang, "Exécuter ?"))?;
|
||||
if ok {
|
||||
executed = true;
|
||||
} else {
|
||||
app.log.info(crate::i18n::tr_in(lang, "annulé"));
|
||||
}
|
||||
}
|
||||
Decision::Execute => {
|
||||
executed = true;
|
||||
}
|
||||
}
|
||||
let code = if executed {
|
||||
app.log.info(&format!("exécution: {}", cmd));
|
||||
let code = crate::shell_ai::execute(app, &cmd)?;
|
||||
code
|
||||
} else {
|
||||
0
|
||||
};
|
||||
app.emit(
|
||||
&Event::now(EventKind::ShellAi)
|
||||
.with_agent("aichat".to_string())
|
||||
.with_args(vec![
|
||||
"mode=exec".to_string(),
|
||||
format!("risk={}", risk.as_str()),
|
||||
format!("certainty={certainty}"),
|
||||
format!("executed={executed}"),
|
||||
]),
|
||||
);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decision_dry_run_is_the_default_policy() {
|
||||
// The embedded config does not set shell_ai -> dry-run default.
|
||||
let s = crate::config::ShellAiSettings::default();
|
||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_files_skips_binary_noise_and_secrets() {
|
||||
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(base.join("target")).unwrap();
|
||||
std::fs::create_dir_all(base.join("src")).unwrap();
|
||||
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
|
||||
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
|
||||
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
|
||||
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
|
||||
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
|
||||
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
|
||||
let files = context_files(&base);
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
@@ -114,14 +114,21 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
|
||||
/// exists. Used by the REPL 'theme <name>' command so a switched theme
|
||||
/// survives a restart.
|
||||
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
|
||||
let path = match &app.config_origin {
|
||||
Some(p) => p.clone(),
|
||||
None => user_config_path(app)?,
|
||||
};
|
||||
let path = active_config_path(app)?;
|
||||
write_dotted_key(&path, key, value)?;
|
||||
reload_and_validate(&path)
|
||||
}
|
||||
|
||||
/// Path of the configuration file that is actually in effect: the loaded
|
||||
/// origin when one exists, otherwise the user config file (created on
|
||||
/// demand). Mutating commands write here so the change survives a reload.
|
||||
pub(crate) fn active_config_path(app: &App) -> Result<PathBuf> {
|
||||
match &app.config_origin {
|
||||
Some(p) => Ok(p.clone()),
|
||||
None => user_config_path(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set one dotted key (e.g. settings.default_shell) in a YAML config file.
|
||||
fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
@@ -155,7 +162,7 @@ fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Reload a written config file to make sure the change stays valid.
|
||||
fn reload_and_validate(path: &Path) -> Result<()> {
|
||||
pub(crate) fn reload_and_validate(path: &Path) -> Result<()> {
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
|
||||
config::load(&cli).map_err(|e| {
|
||||
anyhow!("the new value makes the configuration invalid: {e:#}")
|
||||
@@ -183,7 +190,7 @@ fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Ensure the user config file exists (creating a template when needed).
|
||||
fn user_config_path(app: &App) -> Result<PathBuf> {
|
||||
pub(crate) fn user_config_path(app: &App) -> Result<PathBuf> {
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
|
||||
+120
-2
@@ -8,7 +8,15 @@ use crate::runner::SystemRunner;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent_name: &str,
|
||||
method_sel: Option<&str>,
|
||||
force: bool,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
no_config: bool,
|
||||
) -> Result<i32> {
|
||||
let agent = require_agent(app, agent_name)?;
|
||||
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
|
||||
|
||||
@@ -38,6 +46,20 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
|
||||
let methods = spec.all_methods();
|
||||
let method = select_method(&methods, method_sel)?;
|
||||
|
||||
// Provider/model resolution (issue #90): flag > agent.provider >
|
||||
// settings.default_provider ; flag --model > agent.model >
|
||||
// provider.default_model. Displayed in the summary (and by --dry-run).
|
||||
let resolved = resolve_provider_config(app, agent, provider, model)?;
|
||||
if no_config {
|
||||
app.log
|
||||
.info(crate::i18n::tr("--no-config : aucune configuration post-install"));
|
||||
} else if !spec.configurable {
|
||||
app.log.info(&crate::tr_fmt!(
|
||||
"{} ne prend aucune configuration (install.configurable: false)",
|
||||
agent.name
|
||||
));
|
||||
}
|
||||
|
||||
// Already installed?
|
||||
let existing = app.state.get(&agent.name).ok().flatten();
|
||||
if existing.is_some() && !force && !app.dry_run() {
|
||||
@@ -135,13 +157,28 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
|
||||
}
|
||||
}
|
||||
|
||||
// Post-install provider configuration (issue #91): write the agent
|
||||
// config files with the resolved provider/model. Skipped on --no-config
|
||||
// and on agents declaring install.configurable: false; never reached in
|
||||
// dry-run (returns earlier).
|
||||
if !no_config && spec.configurable {
|
||||
crate::agent_config::apply_post_install(app, agent, &resolved)?;
|
||||
}
|
||||
|
||||
let entry = installers::make_entry(agent, method, &outcome, app);
|
||||
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
app.state.set(&entry)?;
|
||||
let mut args = vec![format!("method={}", method.kind)];
|
||||
if let Some((p, m)) = &resolved {
|
||||
args.push(format!("provider={p}"));
|
||||
if let Some(m) = m {
|
||||
args.push(format!("model={m}"));
|
||||
}
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Install)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![format!("method={}", method.kind)]),
|
||||
.with_args(args),
|
||||
);
|
||||
crate::hooks::run_hooks(
|
||||
app,
|
||||
@@ -161,3 +198,84 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Resolve the provider + model of this install (issue #90) and print what
|
||||
/// will be configured. Returns (provider, model) when resolved.
|
||||
///
|
||||
/// Validation rules:
|
||||
/// - an explicit `--provider` that is not registered → error listing the
|
||||
/// registry (the user asked for it by name);
|
||||
/// - an agent-declared provider that is not registered → warning only
|
||||
/// (catalog metadata may reference a provider the user has not added yet);
|
||||
/// - a model absent from the provider's model list → warning, not blocking.
|
||||
fn resolve_provider_config(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
flag_provider: Option<&str>,
|
||||
flag_model: Option<&str>,
|
||||
) -> Result<Option<(String, Option<String>)>> {
|
||||
let flag_p = flag_provider.filter(|p| !p.is_empty());
|
||||
let agent_p = crate::agent_config::provider_pref(agent).filter(|p| !p.is_empty());
|
||||
|
||||
// Explicit provider requested but not registered: hard error.
|
||||
if let Some(p) = flag_p {
|
||||
if crate::providers::get(&app.config, p).is_none() {
|
||||
let known = crate::providers::names(&app.config);
|
||||
let hint = if known.is_empty() {
|
||||
"am providers add <nom> --base-url <url>".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
};
|
||||
bail!(crate::tr_fmt!(
|
||||
"provider '{}' inconnu — providers enregistrés: {}",
|
||||
p,
|
||||
hint
|
||||
));
|
||||
}
|
||||
}
|
||||
// Agent-declared provider not registered: warn (unless the user already
|
||||
// overrode it with --provider, which was validated above).
|
||||
if let Some(p) = agent_p {
|
||||
if flag_p.is_none() && crate::providers::get(&app.config, p).is_none() {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url <url>",
|
||||
p,
|
||||
agent.name,
|
||||
p
|
||||
));
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let Some((pname, def, model)) = crate::providers::resolve_for(
|
||||
&app.config,
|
||||
crate::agent_config::provider_pref(agent),
|
||||
agent.model.as_deref(),
|
||||
flag_provider,
|
||||
flag_model,
|
||||
) else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"aucun provider configuré — voir: am providers add <nom> --base-url <url>",
|
||||
));
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Model not in the provider's list: warn, keep going.
|
||||
if let Some(m) = model {
|
||||
if !def.models.is_empty() && !def.models.iter().any(|x| x == m) {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"modèle '{}' absent de la liste du provider '{}' — modèles: {}",
|
||||
m,
|
||||
pname,
|
||||
def.models.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let model_label = model.unwrap_or("-");
|
||||
app.log.info(&crate::tr_fmt!(
|
||||
"configuré avec le provider {} (modèle {})",
|
||||
pname,
|
||||
model_label
|
||||
));
|
||||
Ok(Some((pname.to_string(), model.map(String::from))))
|
||||
}
|
||||
|
||||
@@ -266,13 +266,22 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
|
||||
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
|
||||
let rel = Path::new(&bf.path);
|
||||
let target = if bf.path == "config.yaml" {
|
||||
// Config goes to the user config directory of machine B.
|
||||
let dir = crate::config::user_config_dir()
|
||||
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
|
||||
// Config goes to the ACTIVE user config directory of this app
|
||||
// (respects --config / isolated tests) — NOT the machine-global
|
||||
// user config dir: importing must not clobber the real config.
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
dir.join(crate::config::CONFIG_FILE_NAME)
|
||||
} else {
|
||||
state_dir.join(rel)
|
||||
let t = state_dir.join(rel);
|
||||
if !t.starts_with(&state_dir) {
|
||||
anyhow::bail!("security error: bundle contains unsafe path '{}'", bf.path);
|
||||
}
|
||||
t
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -292,8 +301,11 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
|
||||
// confirmed; otherwise it is written next to it as config.migrated.yaml.
|
||||
let cfg_rel = "config.yaml";
|
||||
if restored.iter().any(|r| r == cfg_rel) && !confirm {
|
||||
let dir = crate::config::user_config_dir()
|
||||
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if imported.exists() {
|
||||
let target = dir.join("config.migrated.yaml");
|
||||
|
||||
+99
-5
@@ -1,6 +1,7 @@
|
||||
//! Command implementations and dispatch.
|
||||
|
||||
pub mod agents_cmd;
|
||||
pub mod ai_cmd;
|
||||
pub mod alias_cmd;
|
||||
pub mod annotations_cmd;
|
||||
pub mod audit_cmd;
|
||||
@@ -26,13 +27,15 @@ pub mod migrate_cmd;
|
||||
pub mod open_cmd;
|
||||
pub mod profile_cmd;
|
||||
pub mod projects_cmd;
|
||||
pub mod registry_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod schedule_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod service_cmd;
|
||||
pub mod secret_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod self_update;
|
||||
pub mod serve_cmd;
|
||||
pub mod service_cmd;
|
||||
pub mod sessions_cmd;
|
||||
pub mod stats_cmd;
|
||||
pub mod status_cmd;
|
||||
@@ -40,6 +43,7 @@ pub mod suggest_cmd;
|
||||
pub mod sync_cmd;
|
||||
pub mod playbook_cmd;
|
||||
pub mod plugins_cmd;
|
||||
pub mod providers_cmd;
|
||||
pub mod theme_cmd;
|
||||
pub mod timeline_cmd;
|
||||
pub mod tip_cmd;
|
||||
@@ -98,8 +102,19 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Alias(sub) => alias_cmd::run(app, sub),
|
||||
Command::Secret(sub) => secret_cmd::run(app, sub),
|
||||
Command::Models(args) => models_cmd::run(app, args),
|
||||
Command::Registry(args) => registry_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Catalog(sub) => catalog_cmd::run(app, sub),
|
||||
Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
|
||||
Command::Ai(args) => ai_cmd::run(app, args),
|
||||
Command::Setup { roles } => {
|
||||
if *roles {
|
||||
crate::setup::run_roles(app)
|
||||
} else {
|
||||
crate::setup::run(app)
|
||||
}
|
||||
}
|
||||
Command::Start(a) => run_cmd::start(app, a),
|
||||
Command::Stop {
|
||||
agent,
|
||||
@@ -119,6 +134,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
monitor_cmd::run(app, *interval, *json)
|
||||
}
|
||||
Command::Web(args) => web_cmd::run(app, args.port, args.no_open),
|
||||
Command::Serve(args) => serve_cmd::run(app, args),
|
||||
Command::Sync { message } => sync_cmd::run(app, message.as_deref()),
|
||||
Command::Migrate { export, output, bundle } => {
|
||||
migrate_cmd::run(app, *export, output.as_deref(), bundle.as_deref())
|
||||
@@ -167,7 +183,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
agent,
|
||||
method,
|
||||
force,
|
||||
} => install_cmd::run(app, agent, method.as_deref(), *force),
|
||||
provider,
|
||||
model,
|
||||
no_config,
|
||||
} => install_cmd::run(
|
||||
app,
|
||||
&agent,
|
||||
method.as_deref(),
|
||||
*force,
|
||||
provider.as_deref(),
|
||||
model.as_deref(),
|
||||
*no_config,
|
||||
),
|
||||
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
|
||||
Command::Update { agent, all, rollback } => {
|
||||
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
|
||||
@@ -213,8 +240,16 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Doctor { fix, watch } => {
|
||||
doctor_cmd::run(app, *fix, *watch)
|
||||
}
|
||||
Command::Run { agent, model, container, args } => {
|
||||
run_cmd::run(app, agent, model.as_deref(), *container, args)
|
||||
Command::Run { agent, model, provider, container, no_sandbox, args } => {
|
||||
run_cmd::run(
|
||||
app,
|
||||
agent,
|
||||
model.as_deref(),
|
||||
provider.as_deref(),
|
||||
*container,
|
||||
*no_sandbox,
|
||||
args,
|
||||
)
|
||||
}
|
||||
Command::Service(svc) => match svc {
|
||||
crate::cli::ServiceCmd::Install { agent, autostart } => {
|
||||
@@ -284,6 +319,12 @@ pub fn resolve_exec(
|
||||
Some(e) if !e.bins.is_empty() => {
|
||||
let p = std::path::PathBuf::from(&e.bins[0]);
|
||||
if p.exists() || app.dry_run() {
|
||||
// The recorded bin replaces the run command's first token
|
||||
// (e.g. "picoclaw"): consume it so it is not appended to
|
||||
// the argument list. Regression: am start <agent géré>
|
||||
// lançait "<bin> <token-run>" — picoclaw rejetait son propre
|
||||
// nom comme sous-commande inconnue.
|
||||
tokens.remove(0);
|
||||
p.display().to_string()
|
||||
} else {
|
||||
tokens.remove(0)
|
||||
@@ -304,9 +345,18 @@ pub fn resolve_exec(
|
||||
args.extend(agent.args.iter().cloned());
|
||||
args.extend(extra_args.iter().cloned());
|
||||
let mut env = agent.env.clone();
|
||||
// Issue #91: the `config.env_map` block, resolved against the agent's
|
||||
// provider — api_key stays the @secret reference (resolved below).
|
||||
crate::agent_config::apply_env_map(&mut env, agent, &app.config);
|
||||
env.extend(extra_env.clone());
|
||||
let warnings =
|
||||
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
|
||||
crate::secrets::resolve_env_secrets(
|
||||
&crate::secrets::store(),
|
||||
&agent.name,
|
||||
crate::agent_config::provider_pref(agent)
|
||||
.or_else(|| crate::providers::default_name(&app.config)),
|
||||
&mut env,
|
||||
);
|
||||
for w in warnings {
|
||||
app.log.warn(&w);
|
||||
}
|
||||
@@ -429,6 +479,7 @@ pub fn select_method<'a>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn routes_cmd_scripts_through_comspec() {
|
||||
@@ -454,4 +505,47 @@ mod tests {
|
||||
assert_eq!(program, "node");
|
||||
assert_eq!(args, vec!["-v".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_exec_installed_agent_does_not_leak_run_token() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents:\n - name: x\n run: x\n installable: false\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
app.paths.state_file = dir.join("state.json");
|
||||
app.state = crate::state::StateStore::new(app.paths.state_file.clone());
|
||||
// A recorded installed bin that exists: the run token "x" must be
|
||||
// consumed, not appended to the argument list.
|
||||
let bin = dir.join("x.exe");
|
||||
std::fs::write(&bin, b"x").unwrap();
|
||||
app.state
|
||||
.set(&crate::state::InstalledEntry {
|
||||
name: "x".to_string(),
|
||||
version: Some("1.0".to_string()),
|
||||
method: "binary".to_string(),
|
||||
install_dir: dir.display().to_string(),
|
||||
bins: vec![bin.display().to_string()],
|
||||
run: "x".to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let agent = app.catalog.resolve("x").unwrap();
|
||||
let exec = resolve_exec(&app, agent, &[], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(exec.program, bin.display().to_string());
|
||||
assert!(
|
||||
exec.args.is_empty(),
|
||||
"run token leaked into args: {:?}",
|
||||
exec.args
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! providers: the LLM provider registry (issue #88). `settings.providers`
|
||||
//! maps a provider name to its base URL, model list and default model; the
|
||||
//! default provider is `settings.default_provider`. Tokens are NOT handled
|
||||
//! here — they live in the OS keyring (issue #89).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::ProvidersCmd;
|
||||
use crate::config::ProviderDef;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::print_json;
|
||||
use crate::providers;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use crate::secrets::SecretStore;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
|
||||
match sub {
|
||||
None => list(app),
|
||||
Some(ProvidersCmd::List) => list(app),
|
||||
Some(ProvidersCmd::Show { name }) => show(app, name),
|
||||
Some(ProvidersCmd::Add {
|
||||
name,
|
||||
base_url,
|
||||
model,
|
||||
models,
|
||||
}) => add(app, name, base_url, model.as_deref(), models.as_deref()),
|
||||
Some(ProvidersCmd::Remove { name }) => remove(app, name),
|
||||
Some(ProvidersCmd::SetToken { name, value }) => set_token(app, name, value),
|
||||
Some(ProvidersCmd::Token { name }) => check_token(app, name),
|
||||
Some(ProvidersCmd::Default { name }) => set_default(app, name),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list / show
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let providers = providers::all(&app.config);
|
||||
let default_name = providers::default_name(&app.config);
|
||||
let defined: Vec<(&String, &ProviderDef)> = providers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.as_ref().map(|def| (k, def)))
|
||||
.collect();
|
||||
if app.json() {
|
||||
let rows: Vec<serde_json::Value> = defined
|
||||
.iter()
|
||||
.map(|(name, def)| provider_json(name, def, default_name == Some(name.as_str())))
|
||||
.collect();
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
if defined.is_empty() {
|
||||
app.log.info(&crate::i18n::tr(
|
||||
"aucun provider enregistré — voir: am providers add <nom> --base-url <url>",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = crate::output::Table::new(vec![
|
||||
"PROVIDER",
|
||||
"BASE URL",
|
||||
"DÉFAUT",
|
||||
"MODÈLES",
|
||||
]);
|
||||
for (name, def) in &defined {
|
||||
let star = if default_name == Some(name.as_str()) {
|
||||
"★"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
table.row(vec![
|
||||
format!("{name}{star}"),
|
||||
def.base_url.clone(),
|
||||
def.default_model.clone().unwrap_or_default(),
|
||||
def.models.join(", "),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn show(app: &App, name: &str) -> Result<i32> {
|
||||
let def = providers::get(&app.config, name)
|
||||
.ok_or_else(|| anyhow!(crate::tr_fmt!("provider '{}' inconnu", name)))?;
|
||||
let is_default = providers::default_name(&app.config) == Some(name);
|
||||
if app.json() {
|
||||
print_json(&provider_json(name, def, is_default));
|
||||
return Ok(0);
|
||||
}
|
||||
println!("provider: {name}");
|
||||
if is_default {
|
||||
println!("(provider par défaut)");
|
||||
}
|
||||
println!(" base_url: {}", def.base_url);
|
||||
println!(
|
||||
" modèle par défaut: {}",
|
||||
def.default_model.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(" modèles: {}", def.models.join(", "));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// One provider as a JSON object. Tokens are never part of this shape.
|
||||
fn provider_json(name: &str, def: &ProviderDef, is_default: bool) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"base_url": def.base_url,
|
||||
"default_model": def.default_model,
|
||||
"models": def.models,
|
||||
"is_default": is_default,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add / remove / default / set-token / token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `am providers set-token <nom> --value <v>` — store the provider API token
|
||||
/// in the OS keyring under providers/<nom>/api_key (issue #89). The value
|
||||
/// never leaves the keyring; every agent of this provider resolves it
|
||||
/// through the @secret cascade at start/run.
|
||||
fn set_token(app: &App, name: &str, value: &str) -> Result<i32> {
|
||||
if providers::get(&app.config, name).is_none() {
|
||||
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would store the token of provider {name} in the OS keyring"));
|
||||
return Ok(0);
|
||||
}
|
||||
let key = crate::secrets::key_for_provider_token(name);
|
||||
crate::secrets::store().set(&key, value)?;
|
||||
app.emit(&Event::now(EventKind::Provider).with_args(vec![
|
||||
"action=set-token".to_string(),
|
||||
format!("provider={name}"),
|
||||
]));
|
||||
app.log.success(&crate::tr_fmt!("token stocké pour le provider '{}'", name));
|
||||
app.log.info("shared by every agent of this provider via --env NAME=@secret");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// `am providers token <nom>` — check that a token exists; the value is
|
||||
/// never displayed. --json emits {"provider": ..., "token_present": bool}.
|
||||
fn check_token(app: &App, name: &str) -> Result<i32> {
|
||||
if providers::get(&app.config, name).is_none() {
|
||||
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
||||
}
|
||||
let key = crate::secrets::key_for_provider_token(name);
|
||||
let present = crate::secrets::store().get(&key).ok().flatten().is_some();
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"provider": name,
|
||||
"token_present": present,
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
if present {
|
||||
app.log.success(&crate::tr_fmt!("token présent pour le provider '{}'", name));
|
||||
} else {
|
||||
app.log.info(&crate::tr_fmt!(
|
||||
"aucun token pour le provider '{}' — voir: am providers set-token {}",
|
||||
name,
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result<i32> {
|
||||
if !providers::is_valid_name(name) {
|
||||
bail!(
|
||||
"{}",
|
||||
crate::tr_fmt!(
|
||||
"nom de provider invalide '{}' — attendu un slug [a-z0-9_-]",
|
||||
name
|
||||
)
|
||||
);
|
||||
}
|
||||
if base_url.trim().is_empty() {
|
||||
bail!("{}", crate::i18n::tr("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]"));
|
||||
}
|
||||
let models_list: Vec<String> = models
|
||||
.map(|m| m.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
|
||||
.unwrap_or_default();
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would register provider {name} (base_url={base_url}, default_model={})",
|
||||
model.unwrap_or("-")
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let existed = providers::get(&app.config, name).is_some();
|
||||
let def = ProviderDef {
|
||||
base_url: base_url.trim().to_string(),
|
||||
default_model: model.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()),
|
||||
models: models_list,
|
||||
};
|
||||
write_provider(app, name, &def)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Provider)
|
||||
.with_args(vec![format!("action={}", if existed { "update" } else { "add" }), format!("provider={name}")]),
|
||||
);
|
||||
if existed {
|
||||
app.log.success(&crate::tr_fmt!("provider '{}' mis à jour", name));
|
||||
} else {
|
||||
app.log.success(&crate::tr_fmt!("provider '{}' ajouté", name));
|
||||
app.log.info(&format!(
|
||||
"{}",
|
||||
crate::tr_fmt!(
|
||||
"positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)",
|
||||
name
|
||||
)
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn remove(app: &App, name: &str) -> Result<i32> {
|
||||
if providers::get(&app.config, name).is_none() {
|
||||
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
||||
}
|
||||
if providers::default_name(&app.config) == Some(name) {
|
||||
bail!(crate::tr_fmt!(
|
||||
"le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut",
|
||||
name
|
||||
));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would remove provider {name}"));
|
||||
return Ok(0);
|
||||
}
|
||||
let path = crate::commands::config_cmd::active_config_path(app)?;
|
||||
let mut root = read_yaml(&path)?;
|
||||
// Write an explicit `null` instead of removing the key: the effective
|
||||
// config is a merge (default catalog + user overlay) and a null marker
|
||||
// is what actually deletes a provider shipped in the defaults (issue #90).
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", name],
|
||||
serde_yaml::Value::Null,
|
||||
)?;
|
||||
write_yaml(&path, &root)?;
|
||||
crate::commands::config_cmd::reload_and_validate(&path)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Provider)
|
||||
.with_args(vec!["action=remove".to_string(), format!("provider={name}")]),
|
||||
);
|
||||
app.log.success(&crate::tr_fmt!("provider '{}' supprimé", name));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn set_default(app: &App, name: &str) -> Result<i32> {
|
||||
if providers::get(&app.config, name).is_none() {
|
||||
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would set the default provider to {name}"));
|
||||
return Ok(0);
|
||||
}
|
||||
crate::commands::config_cmd::persist_setting(app, "settings.default_provider", name)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Provider)
|
||||
.with_args(vec!["action=default".to_string(), format!("provider={name}")]),
|
||||
);
|
||||
app.log.success(&crate::tr_fmt!("provider par défaut : {}", name));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config file edition (settings.providers.<name>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Write (or update) one provider entry in the active configuration file.
|
||||
fn write_provider(app: &App, name: &str, def: &ProviderDef) -> Result<()> {
|
||||
let path = crate::commands::config_cmd::active_config_path(app)?;
|
||||
let mut root = read_yaml(&path)?;
|
||||
let base = [
|
||||
"settings",
|
||||
"providers",
|
||||
name,
|
||||
"base_url",
|
||||
];
|
||||
set_path(&mut root, &base, serde_yaml::Value::String(def.base_url.clone()))?;
|
||||
if let Some(m) = &def.default_model {
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", name, "default_model"],
|
||||
serde_yaml::Value::String(m.clone()),
|
||||
)?;
|
||||
}
|
||||
if !def.models.is_empty() {
|
||||
let seq = serde_yaml::Value::Sequence(
|
||||
def.models.iter().map(|m| serde_yaml::Value::String(m.clone())).collect(),
|
||||
);
|
||||
set_path(&mut root, &["settings", "providers", name, "models"], seq)?;
|
||||
}
|
||||
write_yaml(&path, &root)?;
|
||||
crate::commands::config_cmd::reload_and_validate(&path)
|
||||
}
|
||||
|
||||
fn read_yaml(path: &Path) -> Result<serde_yaml::Value> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?;
|
||||
serde_yaml::from_str(&text).map_err(|e| anyhow!("{} is not valid YAML: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn write_yaml(path: &Path, root: &serde_yaml::Value) -> Result<()> {
|
||||
std::fs::write(path, serde_yaml::to_string(root)?)
|
||||
.map_err(|e| anyhow!("cannot write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Set a value at a dotted path, creating intermediate mappings. A null
|
||||
/// value already present on the path (a provider deletion marker from
|
||||
/// `providers remove`, issue #90) is replaced by a fresh mapping.
|
||||
fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Value) -> Result<()> {
|
||||
let mut node = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
let entry = node
|
||||
.entry(serde_yaml::Value::String(part.to_string()))
|
||||
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
|
||||
if entry.is_null() {
|
||||
*entry = serde_yaml::Value::Mapping(Default::default());
|
||||
}
|
||||
node = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
||||
}
|
||||
node.insert(
|
||||
serde_yaml::Value::String(parts.last().unwrap().to_string()),
|
||||
value,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the key at a dotted path (missing keys are a no-op).
|
||||
fn remove_path(root: &mut serde_yaml::Value, parts: &[&str]) -> Result<()> {
|
||||
let mut node = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
let Some(entry) = node.get_mut(serde_yaml::Value::String(part.to_string())) else {
|
||||
return Ok(());
|
||||
};
|
||||
node = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
||||
}
|
||||
node.remove(serde_yaml::Value::String(parts.last().unwrap().to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// App whose active config is a file inside a leaked TempDir (same
|
||||
/// pattern as config_cmd's tests).
|
||||
fn app_with_config(text: &str) -> (App, PathBuf) {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(&cfg, text).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
(app, cfg)
|
||||
}
|
||||
|
||||
const BASE: &str = "version: \"1.0\"\nagents: []\n";
|
||||
|
||||
#[test]
|
||||
fn set_path_descends_and_writes_sequences() {
|
||||
let mut root: serde_yaml::Value = serde_yaml::from_str(BASE).unwrap();
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", "deepseek", "base_url"],
|
||||
serde_yaml::Value::String("https://api.deepseek.com".into()),
|
||||
)
|
||||
.unwrap();
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", "deepseek", "models"],
|
||||
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("deepseek-chat".into())]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(root["settings"]["providers"]["deepseek"]["base_url"], "https://api.deepseek.com");
|
||||
assert_eq!(root["settings"]["providers"]["deepseek"]["models"][0], "deepseek-chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_path_deletes_the_leaf() {
|
||||
let mut root: serde_yaml::Value =
|
||||
serde_yaml::from_str("version: \"1.0\"\nsettings:\n providers:\n openai:\n base_url: x\nagents: []\n")
|
||||
.unwrap();
|
||||
remove_path(&mut root, &["settings", "providers", "openai"]).unwrap();
|
||||
assert!(root["settings"]["providers"].get("openai").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_then_reload_exposes_the_provider() {
|
||||
let (app, cfg) = app_with_config(BASE);
|
||||
add(
|
||||
&app,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com",
|
||||
Some("deepseek-chat"),
|
||||
Some("deepseek-chat, deepseek-reasoner"),
|
||||
)
|
||||
.unwrap();
|
||||
// A fresh load of the same config sees the provider.
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
let def = providers::get(&reloaded.config, "deepseek").expect("provider persisted");
|
||||
assert_eq!(def.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(def.default_model.as_deref(), Some("deepseek-chat"));
|
||||
assert_eq!(def.models, vec!["deepseek-chat", "deepseek-reasoner"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rejects_an_invalid_name_or_missing_url() {
|
||||
let (app, _cfg) = app_with_config(BASE);
|
||||
assert!(add(&app, "OpenAI", "https://x", None, None).is_err());
|
||||
assert!(add(&app, "openai", " ", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_and_set_default_flow() {
|
||||
// Config mutations apply on the next process (same convention as
|
||||
// alias/config set): reload between operations like a real CLI user.
|
||||
let (app, cfg) = app_with_config(BASE);
|
||||
add(&app, "openai", "https://api.openai.com/v1", Some("gpt-5.2"), None).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
set_default(&reloaded, "openai").unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
assert_eq!(providers::default_name(&reloaded.config), Some("openai"));
|
||||
// Cannot remove the default provider.
|
||||
assert!(remove(&reloaded, "openai").is_err());
|
||||
// Removing an unknown provider is an error too.
|
||||
assert!(remove(&reloaded, "nope").is_err());
|
||||
// After changing the default, removal works.
|
||||
add(&reloaded, "deepseek", "https://api.deepseek.com", None, None).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
set_default(&reloaded, "deepseek").unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
assert!(remove(&reloaded, "openai").is_ok());
|
||||
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded2 = App::from_cli(cli2).unwrap();
|
||||
assert!(providers::get(&reloaded2.config, "openai").is_none());
|
||||
assert_eq!(providers::default_name(&reloaded2.config), Some("deepseek"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_writes_nothing() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(&cfg, BASE).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--dry-run", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
// "localai" is not shipped in the embedded default catalog, so its
|
||||
// presence proves a real write happened.
|
||||
add(&app, "localai", "http://127.0.0.1:8080/v1", None, None).unwrap();
|
||||
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli2).unwrap();
|
||||
assert!(providers::get(&reloaded.config, "localai").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! am registry — community registry of agent catalogs (issue #77).
|
||||
//! publish (manifest), search (across settings.registry.sources),
|
||||
//! install (manifest + checksum + explicit trust), list.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, sub: Option<&crate::cli::RegistryCmd>) -> Result<i32> {
|
||||
match sub {
|
||||
None => crate::registry::list(app),
|
||||
Some(crate::cli::RegistryCmd::List) => crate::registry::list(app),
|
||||
Some(crate::cli::RegistryCmd::Publish {
|
||||
catalog,
|
||||
source,
|
||||
author,
|
||||
version,
|
||||
}) => crate::registry::publish(
|
||||
app,
|
||||
catalog,
|
||||
source.as_deref(),
|
||||
author.as_deref(),
|
||||
version.as_deref(),
|
||||
),
|
||||
Some(crate::cli::RegistryCmd::Search { query }) => crate::registry::search(app, query),
|
||||
Some(crate::cli::RegistryCmd::Install { url, yes }) => {
|
||||
crate::registry::install(app, url, *yes)
|
||||
}
|
||||
}
|
||||
}
|
||||
+286
-10
@@ -71,13 +71,30 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
)?;
|
||||
// --model (issue #71): resolve against the local runtimes, then
|
||||
// transmit through the agent's declared channel (env var or flag).
|
||||
// --provider (issue #92): resolve from the LLM provider registry.
|
||||
let mut extra_args = extra_args;
|
||||
let mut extra_env = extra_env;
|
||||
if let Some(m) = opts.model.as_deref() {
|
||||
if let Some(p) = opts.provider.as_deref() {
|
||||
let agent = require_agent(app, &target)?;
|
||||
let (a, e) = apply_model(app, agent, m)?;
|
||||
let (a, e) = apply_provider(app, agent, Some(p), opts.model.as_deref())?
|
||||
.expect("a provider flag was given: apply_provider always resolves or errors");
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
} else if let Some(m) = opts.model.as_deref() {
|
||||
let agent = require_agent(app, &target)?;
|
||||
match apply_model(app, agent, m) {
|
||||
Ok((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
|
||||
Some((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
None => return Err(local_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||
let members = app.catalog.group_members(group);
|
||||
@@ -88,7 +105,7 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
// waiting (the OS spawns them concurrently); the default waits each
|
||||
// health check before the next member.
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, cwd)?;
|
||||
if !opts.parallel {
|
||||
// Issue #57: block until healthy before the next member.
|
||||
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
|
||||
@@ -97,7 +114,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
return Ok(0);
|
||||
}
|
||||
let agent = require_agent(app, &target)?;
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd)
|
||||
start_one(
|
||||
app,
|
||||
agent,
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
opts.notify,
|
||||
opts.background,
|
||||
opts.no_sandbox,
|
||||
cwd,
|
||||
)
|
||||
}
|
||||
|
||||
fn start_one(
|
||||
@@ -107,6 +133,7 @@ fn start_one(
|
||||
extra_env: &BTreeMap<String, String>,
|
||||
notify: bool,
|
||||
background: bool,
|
||||
no_sandbox: bool,
|
||||
cwd: Option<&std::path::Path>,
|
||||
) -> Result<i32> {
|
||||
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
||||
@@ -116,7 +143,10 @@ fn start_one(
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t));
|
||||
if background {
|
||||
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?;
|
||||
// Issue #79: sandbox enforcement before spawning.
|
||||
let mut env = exec.env.clone();
|
||||
crate::sandbox::enforce(app, agent, &exec.program, &mut env, no_sandbox)?;
|
||||
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &env, cwd)?;
|
||||
if app.dry_run() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -306,13 +336,31 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
||||
stop_one(app, m, force, timeout)?;
|
||||
}
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?;
|
||||
start_one(
|
||||
app,
|
||||
m,
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
opts.notify,
|
||||
true,
|
||||
opts.no_sandbox,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let agent = require_agent(app, &target)?;
|
||||
stop_one(app, agent, force, timeout)?;
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None)
|
||||
start_one(
|
||||
app,
|
||||
agent,
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
opts.notify,
|
||||
opts.background,
|
||||
opts.no_sandbox,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// run: execute the agent command directly, no process management.
|
||||
@@ -320,7 +368,9 @@ pub fn run(
|
||||
app: &App,
|
||||
target: &str,
|
||||
model: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
container: bool,
|
||||
no_sandbox: bool,
|
||||
extra: &[OsString],
|
||||
) -> Result<i32> {
|
||||
let agent = require_agent(app, target)?;
|
||||
@@ -333,12 +383,30 @@ pub fn run(
|
||||
.map(|o| o.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
|
||||
if let Some(m) = model {
|
||||
let (a, e) = apply_model(app, agent, m)?;
|
||||
// Issue #92: --provider resolves from the LLM provider registry (cloud);
|
||||
// --model alone keeps the local-runtimes-first behavior (issue #71) with
|
||||
// a cloud fallback when the model is unknown locally.
|
||||
if let Some(p) = provider {
|
||||
let (a, e) = apply_provider(app, agent, Some(p), model)?
|
||||
.expect("a provider flag was given: apply_provider always resolves or errors");
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
} else if let Some(m) = model {
|
||||
match apply_model(app, agent, m) {
|
||||
Ok((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
|
||||
Some((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
None => return Err(local_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
|
||||
let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would run {} {}",
|
||||
@@ -351,6 +419,9 @@ pub fn run(
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
// Issue #79: sandbox enforcement (command allowlist, cwd perimeter,
|
||||
// network policy) — refused attempts are journalized for audit.
|
||||
crate::sandbox::enforce(app, agent, &prog, &mut exec.env, no_sandbox)?;
|
||||
let status = Command::new(&prog)
|
||||
.args(&full_args)
|
||||
.envs(&exec.env)
|
||||
@@ -474,6 +545,70 @@ pub fn apply_model(
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// Issue #92: resolve `--provider`/`--model` against the LLM provider
|
||||
/// registry (cloud) and transmit through the agent's declared channels:
|
||||
/// `model_env` / `model_arg`, or the `config.env_map` slots (issue #91).
|
||||
/// The token travels as the `@secret` reference resolved by
|
||||
/// resolve_env_secrets — never on the command line. Returns None when no
|
||||
/// provider is configured at all (the caller falls back to the
|
||||
/// local-runtime path).
|
||||
fn apply_provider(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Option<(Vec<String>, BTreeMap<String, String>)>> {
|
||||
let Some((pname, _def, resolved_model)) = crate::providers::resolve_for(
|
||||
&app.config,
|
||||
crate::agent_config::provider_pref(agent),
|
||||
agent.model.as_deref(),
|
||||
provider,
|
||||
model,
|
||||
) else {
|
||||
if provider.is_some() {
|
||||
let known = crate::providers::names(&app.config);
|
||||
let hint = if known.is_empty() {
|
||||
"aucun (am providers add <nom> --base-url <url>)".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
};
|
||||
bail!(crate::tr_fmt!(
|
||||
"provider '{}' inconnu — providers enregistrés: {}",
|
||||
provider.unwrap_or(""),
|
||||
hint
|
||||
));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
||||
// env_map resolved against THIS provider (api_key stays @secret).
|
||||
crate::agent_config::apply_env_map_resolved(
|
||||
&mut env,
|
||||
agent,
|
||||
&app.config,
|
||||
Some(pname),
|
||||
resolved_model,
|
||||
);
|
||||
// The model through the agent's declared channel (model_env > model_arg);
|
||||
// otherwise the env_map "model" slot already carried it.
|
||||
if let Some(m) = resolved_model {
|
||||
if let Some(var) = &agent.model_env {
|
||||
env.insert(var.clone(), m.to_string());
|
||||
} else if let Some(flag) = &agent.model_arg {
|
||||
args.push(flag.clone());
|
||||
args.push(m.to_string());
|
||||
}
|
||||
}
|
||||
app.log.info(&crate::tr_fmt!(
|
||||
"provider {} (modèle {}) au lancement de {}",
|
||||
pname,
|
||||
resolved_model.unwrap_or("-"),
|
||||
agent.name
|
||||
));
|
||||
Ok(Some((args, env)))
|
||||
}
|
||||
|
||||
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
|
||||
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
||||
@@ -483,3 +618,144 @@ fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
.num_seconds();
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AgentConfig, AgentDef, Config};
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(providers_yaml: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
crate::app::App::from_cli(cli).unwrap()
|
||||
}
|
||||
|
||||
fn agent(model_env: bool, model_arg: bool) -> AgentDef {
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
|
||||
env_map.insert("model".to_string(), "MY_MODEL".to_string());
|
||||
env_map.insert("base_url".to_string(), "MY_URL".to_string());
|
||||
AgentDef {
|
||||
name: "demo".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
category: None,
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("demo".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: model_env.then(|| "MY_MODEL".to_string()),
|
||||
model_arg: model_arg.then(|| "--model".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
config: Some(AgentConfig {
|
||||
env_map,
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
}),
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
const OPENAI: &str = " default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n";
|
||||
|
||||
#[test]
|
||||
fn apply_provider_resolves_and_never_leaks_the_token() {
|
||||
let app = test_app(OPENAI);
|
||||
let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// The token slot is the @secret reference — never a value.
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_uses_the_declared_model_channel() {
|
||||
let app = test_app(OPENAI);
|
||||
// model_env wins over the env_map slot.
|
||||
let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert!(args.is_empty());
|
||||
// model_arg is pushed on the command line.
|
||||
let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_unknown_is_an_error_listing_the_registry() {
|
||||
let app = test_app(OPENAI);
|
||||
let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("nope"), "{msg}");
|
||||
assert!(msg.contains("openai"), "registry list missing: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_falls_back_to_the_embedded_default_provider() {
|
||||
// The embedded default catalog ships providers (anthropic★ since
|
||||
// issue #90): even an empty user config resolves through the cloud.
|
||||
let app = test_app("");
|
||||
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x"))
|
||||
.unwrap()
|
||||
.expect("the embedded default provider resolves");
|
||||
// Model "x" is unknown: the env_map model slot is skipped, but the
|
||||
// provider slot is wired.
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1");
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_flag_falls_back_to_the_cloud_registry() {
|
||||
let app = test_app(OPENAI);
|
||||
// apply_model bails (no local runtime in the test env) → the caller
|
||||
// falls back to apply_provider with the default provider.
|
||||
let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err();
|
||||
assert!(local.to_string().contains("not found on any local runtime"));
|
||||
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_parses_the_provider_registry_and_agents() {
|
||||
let _cfg: Config = serde_yaml::from_str(&format!(
|
||||
"version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,65 @@
|
||||
//! secret: manage secrets in the OS keyring (values never shown or logged).
|
||||
//! Secrets are scoped to an agent (`agent/NAME`, issue #36) or to a provider
|
||||
//! (`providers/<provider>/<VAR>`, issue #89) — the provider namespace is
|
||||
//! shared by every agent of that provider.
|
||||
|
||||
use super::*;
|
||||
use crate::cli::SecretCmd;
|
||||
use crate::output::print_json;
|
||||
use crate::secrets::{self, SecretStore};
|
||||
|
||||
/// Resolve the keyring key and the display scope for a secret name.
|
||||
/// Exactly one of (agent, provider) must be given.
|
||||
fn scope_key(
|
||||
name: &str,
|
||||
agent: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
) -> Result<(String, String)> {
|
||||
match (provider, agent) {
|
||||
(Some(p), _) => Ok((secrets::key_for_provider(p, name), format!("provider {p}"))),
|
||||
(None, Some(a)) => Ok((secrets::key_for(a, name), format!("agent {a}"))),
|
||||
(None, None) => bail!(
|
||||
"{}",
|
||||
crate::i18n::tr(
|
||||
"usage: secret set <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>"
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
|
||||
match sub {
|
||||
SecretCmd::Set { name, agent, value } => {
|
||||
SecretCmd::Set {
|
||||
name,
|
||||
agent,
|
||||
provider,
|
||||
value,
|
||||
} => {
|
||||
let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would store secret {name} for {agent} in the OS keyring"));
|
||||
app.log.dry(format!("would store secret {name} for {scope} in the OS keyring"));
|
||||
return Ok(0);
|
||||
}
|
||||
let key = secrets::key_for(agent, name);
|
||||
secrets::store().set(&key, value)?;
|
||||
app.log.success(&format!("secret {name} stored for {agent}"));
|
||||
app.log.success(&crate::tr_fmt!("secret '{}' stocké pour {}", name, scope));
|
||||
app.log.info("use it with --env NAME=@secret (injected at start/run)");
|
||||
Ok(0)
|
||||
}
|
||||
SecretCmd::Unset { name, agent } => {
|
||||
let key = secrets::key_for(agent, name);
|
||||
SecretCmd::Unset {
|
||||
name,
|
||||
agent,
|
||||
provider,
|
||||
} => {
|
||||
let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove secret {key}"));
|
||||
return Ok(0);
|
||||
}
|
||||
let removed = secrets::store().remove(&key)?;
|
||||
if removed {
|
||||
app.log.success(&format!("secret {name} removed for {agent}"));
|
||||
app.log.success(&crate::tr_fmt!("secret '{}' supprimé pour {}", name, scope));
|
||||
} else {
|
||||
app.log.info(&format!("secret {name} does not exist for {agent}"));
|
||||
app.log.info(&crate::tr_fmt!("secret '{}' inexistant pour {}", name, scope));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
@@ -42,6 +73,7 @@ pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
|
||||
app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ...");
|
||||
return Ok(0);
|
||||
}
|
||||
// Both namespaces appear here: agent/NAME and providers/<p>/<VAR>.
|
||||
let mut table = crate::output::Table::new(vec!["SECRET"]);
|
||||
for k in all {
|
||||
if k != "__index__" {
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::*;
|
||||
use crate::download;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::cmp::Ordering;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
|
||||
let Some(repo) = app.config.settings.self_update_repo.clone() else {
|
||||
@@ -51,19 +51,26 @@ pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let dest = to.map(|p| p.to_path_buf()).unwrap_or_else(|| current_exe.clone());
|
||||
let tmpdir = tempfile::tempdir()?;
|
||||
let tmp = tmpdir.path().join("am.new");
|
||||
let tmp = tmpdir.path().join("am.dl");
|
||||
download::download(&picked.url, &tmp, None, true)?;
|
||||
// Release assets are archives (zip on Windows, tar.gz elsewhere):
|
||||
// unpack them and locate the binary inside.
|
||||
let staged = if download::extract_archive(&tmp, tmpdir.path())? {
|
||||
find_binary(tmpdir.path())?
|
||||
} else {
|
||||
tmp
|
||||
};
|
||||
if dest == current_exe && cfg!(windows) {
|
||||
// The running executable is locked on Windows.
|
||||
let new_path = current_exe.with_extension("exe.new");
|
||||
std::fs::copy(&tmp, &new_path)
|
||||
std::fs::copy(&staged, &new_path)
|
||||
.with_context(|| format!("cannot write {}", new_path.display()))?;
|
||||
app.log.success(&format!(
|
||||
"downloaded {tag} to {} — replace the running binary manually",
|
||||
new_path.display()
|
||||
));
|
||||
} else {
|
||||
std::fs::copy(&tmp, &dest)
|
||||
std::fs::copy(&staged, &dest)
|
||||
.with_context(|| format!("cannot write {}", dest.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -79,3 +86,66 @@ pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the binary inside an unpacked release archive.
|
||||
fn find_binary(dir: &Path) -> Result<PathBuf> {
|
||||
let mut entries: Vec<PathBuf> = Vec::new();
|
||||
collect_files(dir, &mut entries);
|
||||
if entries.is_empty() {
|
||||
bail!("no binary found in the release archive");
|
||||
}
|
||||
// Prefer a file named exactly `am` / `am.exe`, otherwise the largest
|
||||
// file (the archive only contains the binary).
|
||||
entries.sort_by_key(|p| {
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let prefer = name == "am" || name == "am.exe" || name.ends_with(".exe");
|
||||
(
|
||||
!prefer,
|
||||
std::cmp::Reverse(std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)),
|
||||
)
|
||||
});
|
||||
Ok(entries.remove(0))
|
||||
}
|
||||
|
||||
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect_files(&p, out);
|
||||
} else {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_binary_prefers_am_exe_in_zip_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("README.md"), "docs").unwrap();
|
||||
std::fs::write(dir.path().join("am-windows-x86_64.exe"), b"exe").unwrap();
|
||||
std::fs::write(dir.path().join("am-linux-x86_64"), b"elf").unwrap();
|
||||
let found = find_binary(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
found.file_name().unwrap().to_str().unwrap(),
|
||||
"am-windows-x86_64.exe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_binary_falls_back_to_largest_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("am"), b"small").unwrap();
|
||||
std::fs::write(dir.path().join("data.bin"), vec![0u8; 4096]).unwrap();
|
||||
let found = find_binary(dir.path()).unwrap();
|
||||
assert_eq!(found.file_name().unwrap().to_str().unwrap(), "am");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//! am serve — authenticated HTTP + WebSocket API (issue #80).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::ServeArgs;
|
||||
|
||||
pub fn run(app: &App, args: &ServeArgs) -> Result<i32> {
|
||||
let Some(token) = args.token.clone() else {
|
||||
anyhow::bail!("am serve exige --token <token> — le serveur refuse toute requête sans ce token");
|
||||
};
|
||||
crate::serve::run(
|
||||
app,
|
||||
&token,
|
||||
args.host.as_deref().unwrap_or("127.0.0.1"),
|
||||
args.port.unwrap_or(crate::serve::DEFAULT_PORT),
|
||||
args.rate_limit.unwrap_or(crate::serve::DEFAULT_RATE_LIMIT),
|
||||
)
|
||||
}
|
||||
@@ -190,12 +190,18 @@ mod tests {
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some(name.to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: None,
|
||||
model_arg: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
config: None,
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||
installable: false,
|
||||
note: None,
|
||||
|
||||
@@ -103,6 +103,34 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[("--json", "sortie machine-readable")],
|
||||
example: "suggest un agent pour du Python",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "ask <demande>",
|
||||
about: "langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (issue #78)",
|
||||
options: &[("--yes", "exécute la commande sans confirmation")],
|
||||
example: "ask installe claude et lance-le",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "ai <demande> [--exec] [--files <path>] [--role <rôle>]",
|
||||
about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut",
|
||||
options: &[
|
||||
("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"),
|
||||
("-f, --files", "fichiers/dossiers en contexte (défaut: dossier courant)"),
|
||||
("--role", "rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
|
||||
("--provider", "provider du registre (clé du keyring + modèle)"),
|
||||
("--model", "forcer un modèle"),
|
||||
("--yes", "exécute sans confirmation"),
|
||||
],
|
||||
example: "ai --exec \"compresse les fichiers JSON en un zip\"",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "setup [--roles]",
|
||||
about: "wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat, rôles copilot am-* (v1.1.0)",
|
||||
options: &[
|
||||
("--roles", "régénérer uniquement les rôles copilot aichat"),
|
||||
("--yes", "non-interactif (valeurs actuelles)"),
|
||||
],
|
||||
example: "setup",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "models",
|
||||
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
|
||||
@@ -119,6 +147,15 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
|
||||
example: "catalog add https://git.dracodev.net/team/agents.yaml",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "registry publish <catalogue.yaml> --source <url>",
|
||||
about: "registre communautaire : publie, cherche ou installe un catalogue (manifeste + checksum sha256 vérifié)",
|
||||
options: &[
|
||||
("search <mot>", "cherche dans le registre"),
|
||||
("install <url>", "installe après vérification du checksum et décision de confiance"),
|
||||
],
|
||||
example: "registry search agents python",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "audit",
|
||||
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
|
||||
@@ -251,6 +288,16 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
],
|
||||
example: "web --port 9090",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "serve --token <tok>",
|
||||
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask) (issue #80)",
|
||||
options: &[
|
||||
("--port <p>", "port d'écoute (défaut 8080)"),
|
||||
("--host <h>", "adresse d'écoute (défaut 127.0.0.1)"),
|
||||
("--rate-limit <n>", "requêtes par IP et par minute (défaut 120)"),
|
||||
],
|
||||
example: "serve --token mon-token --port 9000",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "sync",
|
||||
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
|
||||
@@ -323,6 +370,12 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[],
|
||||
example: "secret set API_KEY --agent pi --value sk-…",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
|
||||
about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring",
|
||||
options: &[],
|
||||
example: "providers set-token deepseek --value sk-…",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "favorite · note · tag",
|
||||
about: "annotez vos agents : étoile, note libre, tags personnels",
|
||||
|
||||
+313
@@ -136,9 +136,148 @@ pub struct Settings {
|
||||
/// Push automatically when the REPL exits (issue #66, opt-in).
|
||||
#[serde(default)]
|
||||
pub sync_on_exit: Option<bool>,
|
||||
/// Anonymous opt-in telemetry (issue #76): aggregated counters only.
|
||||
/// Nothing is collected or sent while `enabled` is false (the default).
|
||||
#[serde(default)]
|
||||
pub telemetry: Option<TelemetrySettings>,
|
||||
/// am ask (issue #78): natural language → am command. The local rules
|
||||
/// are always available; the optional LLM refinement uses the provider
|
||||
/// registry. `enabled: false` turns the whole command off.
|
||||
#[serde(default)]
|
||||
pub ask: Option<AskSettings>,
|
||||
/// Community registry (issue #77): trusted catalog sources + the author
|
||||
/// name recorded by `am registry publish`.
|
||||
#[serde(default)]
|
||||
pub registry: Option<RegistrySettings>,
|
||||
/// Plugin scripts (issue #75): default timeout and enable list.
|
||||
#[serde(default)]
|
||||
pub plugins: Option<PluginSettings>,
|
||||
/// Name of the provider used by default (issue #88) — the provider
|
||||
/// whose default model is applied at install/run when none is given.
|
||||
#[serde(default)]
|
||||
pub default_provider: Option<String>,
|
||||
/// LLM provider registry (issue #88): provider name -> base URL,
|
||||
/// available models and default model. API tokens are NOT stored here —
|
||||
/// they live in the OS keyring under `providers/<name>/api_key`
|
||||
/// (issue #89).
|
||||
#[serde(default)]
|
||||
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
|
||||
/// am ai security settings (issue #97): default safety policy and
|
||||
/// extra risky command patterns for the Shell AI command.
|
||||
#[serde(default)]
|
||||
pub shell_ai: Option<ShellAiSettings>,
|
||||
}
|
||||
|
||||
/// Anonymous opt-in telemetry (issue #76): aggregated counters only — never
|
||||
/// paths, commands, agent names or identifiers. Disabled by default.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct TelemetrySettings {
|
||||
/// Explicit opt-in: nothing is collected or sent while false.
|
||||
pub enabled: bool,
|
||||
/// Batch endpoint (e.g. https://am-telemetry.example/v1/ping). When
|
||||
/// unset, counters stay local even when enabled.
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// am ask settings (issue #78): the LLM refinement is optional — without a
|
||||
/// provider, the local rule-based translator still works, offline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AskSettings {
|
||||
/// Master switch; false disables `am ask` entirely (issue #78).
|
||||
pub enabled: bool,
|
||||
/// Provider of the registry used for the refinement call. Falls back
|
||||
/// to settings.default_provider when unset. None = rules only.
|
||||
pub provider: Option<String>,
|
||||
/// Model for the refinement call. Falls back to the provider default.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AskSettings {
|
||||
fn default() -> Self {
|
||||
AskSettings {
|
||||
enabled: true,
|
||||
provider: None,
|
||||
model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// am ai security settings (issue #97): safety policy and risky patterns
|
||||
/// for the Shell AI command. The default policy is `dry-run` — nothing is
|
||||
/// executed without an explicit confirmation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ShellAiSettings {
|
||||
/// Default safety policy of `am ai --exec`:
|
||||
/// `dry-run` (show only), `confirm` (ask for risky commands) or
|
||||
/// `auto` (execute everything). Default: dry-run.
|
||||
pub default_safety: Option<String>,
|
||||
/// Extra command substrings classified as risky (in addition to the
|
||||
/// built-in patterns: rm -rf, dd if, mkfs, chmod -R 777, ...).
|
||||
#[serde(default)]
|
||||
pub risky_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShellAiSettings {
|
||||
/// The effective default safety mode (unknown values fall back to
|
||||
/// dry-run — the safe choice).
|
||||
pub fn safety_mode(&self) -> crate::shell_ai::SafetyMode {
|
||||
match self.default_safety.as_deref() {
|
||||
Some("auto") => crate::shell_ai::SafetyMode::Auto,
|
||||
Some("confirm") => crate::shell_ai::SafetyMode::Confirm,
|
||||
_ => crate::shell_ai::SafetyMode::DryRun,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Community registry settings (issue #77).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RegistrySettings {
|
||||
/// Trusted catalog sources: their manifests are validated (checksum)
|
||||
/// and their agents searchable through `am registry search`.
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
/// Author name recorded by `am registry publish` (default: "unknown").
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
/// Sandbox profile of an agent (issue #79): restrict which commands it may
|
||||
/// run, which working directories it may use, and whether it may reach the
|
||||
/// network. Enforcement is best-effort per platform — the launcher checks
|
||||
/// the resolved command and the process cwd, and a warning is emitted when
|
||||
/// the enforcement cannot be fully guaranteed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct SandboxProfile {
|
||||
/// Sandbox active for this agent (default false).
|
||||
pub enabled: bool,
|
||||
/// Allowed command names (basenames). Empty = no command restriction.
|
||||
#[serde(default)]
|
||||
pub commands: Vec<String>,
|
||||
/// Allowed working directories (support ~ and env vars). Empty = no
|
||||
/// directory restriction.
|
||||
#[serde(default)]
|
||||
pub dirs: Vec<String>,
|
||||
/// Network access (default true). false = best-effort block.
|
||||
#[serde(default = "default_true")]
|
||||
pub network: bool,
|
||||
}
|
||||
|
||||
/// One entry of the LLM provider registry (issue #88).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProviderDef {
|
||||
/// Base URL of the provider API (e.g. https://api.openai.com/v1).
|
||||
pub base_url: String,
|
||||
/// Model used by default for this provider.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
/// Models offered by this provider.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Plugin scripts settings (issue #75): how long one plugin may run and
|
||||
@@ -248,6 +387,27 @@ impl Settings {
|
||||
self.stop_timeout_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up one provider of the registry (issue #88).
|
||||
pub fn provider(&self, name: &str) -> Option<&ProviderDef> {
|
||||
self.providers
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(name))
|
||||
.and_then(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Name of the default provider (issue #88).
|
||||
pub fn default_provider_name(&self) -> Option<&str> {
|
||||
self.default_provider.as_deref()
|
||||
}
|
||||
|
||||
/// The default provider entry, when both the name and the definition
|
||||
/// exist (issue #88).
|
||||
pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> {
|
||||
let name = self.default_provider.as_ref()?;
|
||||
let def = self.provider(name)?;
|
||||
Some((name, def))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -270,6 +430,13 @@ pub struct AgentDef {
|
||||
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
|
||||
#[serde(default)]
|
||||
pub run: Option<String>,
|
||||
/// Binary name probed on the PATH for external detection. When unset,
|
||||
/// the first token of `run` is used — which is wrong for interpreter
|
||||
/// launchers ("npx pkg", "python -m pkg"): the interpreter itself would
|
||||
/// be matched, reporting the agent as present whenever node/python is
|
||||
/// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai").
|
||||
#[serde(default)]
|
||||
pub detect: Option<String>,
|
||||
/// Default arguments appended to the run command.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
@@ -289,6 +456,28 @@ pub struct AgentDef {
|
||||
/// CLI flag carrying the model name at launch (issue #71, e.g. "--model").
|
||||
#[serde(default)]
|
||||
pub model_arg: Option<String>,
|
||||
/// Preferred provider of this agent (issue #90, e.g. "anthropic" for
|
||||
/// claude-code). Falls back to settings.default_provider when unset.
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
/// Preferred model for this agent (issue #90). Falls back to the
|
||||
/// provider's default_model when unset.
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// Post-install provider configuration (issue #91).
|
||||
#[serde(default)]
|
||||
pub config: Option<AgentConfig>,
|
||||
/// Manual setup commands shown after install when the agent manages its
|
||||
/// own configuration (onboard/model/auth...). Placeholders {model},
|
||||
/// {provider} and {base_url} are substituted with the resolved values
|
||||
/// (v1.1.4).
|
||||
#[serde(default)]
|
||||
pub setup_hints: Vec<String>,
|
||||
/// Sandbox profile (issue #79): allowed commands, working directories
|
||||
/// and network policy. Disabled by default — the unsandboxed mode
|
||||
/// stays available and documented.
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<SandboxProfile>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// When false, the agent is listed but cannot be installed locally.
|
||||
@@ -372,6 +561,12 @@ impl AgentDef {
|
||||
})
|
||||
}
|
||||
|
||||
/// Binary probed on the PATH for external detection: the explicit
|
||||
/// `detect` name when set, otherwise the first token of `run`.
|
||||
pub fn detect_token(&self) -> Option<String> {
|
||||
self.detect.clone().or_else(|| self.first_token())
|
||||
}
|
||||
|
||||
/// The run command split into tokens (empty when unset).
|
||||
pub fn run_tokens(&self) -> Vec<String> {
|
||||
self.run
|
||||
@@ -546,6 +741,10 @@ pub struct InstallSpec {
|
||||
/// Commands executed after a successful install (PATH includes the bin dir).
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
/// The agent accepts a post-install provider configuration (issue #91).
|
||||
/// False for agents without any API (issue #90).
|
||||
#[serde(default = "default_true")]
|
||||
pub configurable: bool,
|
||||
}
|
||||
|
||||
impl InstallSpec {
|
||||
@@ -559,6 +758,33 @@ impl InstallSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-install provider configuration of an agent (issue #91): the env vars
|
||||
/// the agent expects and the config files to write or softly edit. The
|
||||
/// api_key slot is always injected as the `@secret` reference (issue #89) —
|
||||
/// a token is never written in clear.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AgentConfig {
|
||||
/// Expected env vars, keyed by semantic slot (api_key / model /
|
||||
/// base_url / provider) -> the agent's variable name.
|
||||
pub env_map: BTreeMap<String, String>,
|
||||
/// Configuration files to write or softly edit after the install.
|
||||
pub files: Vec<AgentConfigFile>,
|
||||
/// Provider of this agent, otherwise settings.default_provider.
|
||||
pub provider_default: Option<String>,
|
||||
}
|
||||
|
||||
/// One config file of an agent (issue #91).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentConfigFile {
|
||||
/// File path (supports ~ and env vars).
|
||||
pub path: String,
|
||||
/// Keys to write among: api_key, model, base_url, provider.
|
||||
#[serde(default)]
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
@@ -766,9 +992,45 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.sync_on_exit.is_some() {
|
||||
s.sync_on_exit = o.sync_on_exit;
|
||||
}
|
||||
if o.telemetry.is_some() {
|
||||
s.telemetry = o.telemetry;
|
||||
}
|
||||
if o.ask.is_some() {
|
||||
s.ask = o.ask;
|
||||
}
|
||||
if o.registry.is_some() {
|
||||
s.registry = o.registry;
|
||||
}
|
||||
if o.plugins.is_some() {
|
||||
s.plugins = o.plugins;
|
||||
}
|
||||
if o.default_provider.is_some() {
|
||||
s.default_provider = o.default_provider;
|
||||
}
|
||||
if o.shell_ai.is_some() {
|
||||
s.shell_ai = o.shell_ai;
|
||||
}
|
||||
// Providers merge key by key (issue #88): an overlay adds or replaces
|
||||
// one provider without wiping the others. An explicit `null` in the
|
||||
// overlay DELETES the provider (the standard YAML overlay pattern) so a
|
||||
// provider shipped in the default catalog can be removed by the user.
|
||||
if let Some(overlay_providers) = o.providers {
|
||||
match s.providers.as_mut() {
|
||||
Some(map) => {
|
||||
for (k, v) in overlay_providers {
|
||||
match v {
|
||||
Some(def) => {
|
||||
map.insert(k, Some(def));
|
||||
}
|
||||
None => {
|
||||
map.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => s.providers = Some(overlay_providers),
|
||||
}
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
@@ -834,6 +1096,32 @@ pub fn validate(config: &Config) -> Vec<String> {
|
||||
problems.push(format!("agent '{}': dependency without a name", agent.name));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &config.settings.default_provider {
|
||||
if config.settings.provider(d).is_none() {
|
||||
problems.push(format!(
|
||||
"settings.default_provider '{d}' is not defined in settings.providers"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(providers) = &config.settings.providers {
|
||||
for (name, p) in providers {
|
||||
// A null entry is a deletion marker (issue #90): skip it.
|
||||
let Some(p) = p else {
|
||||
continue;
|
||||
};
|
||||
if p.base_url.trim().is_empty() {
|
||||
problems.push(format!("provider '{name}': 'base_url' is required"));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
problems.push(format!(
|
||||
"provider name '{name}' must be a lowercase slug ([a-z0-9_-])"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
@@ -933,6 +1221,31 @@ mod tests {
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_token_prefers_explicit_binary() {
|
||||
// Interpreter launcher: without `detect`, the interpreter itself
|
||||
// would be probed (false "external" when node/python is on PATH).
|
||||
let a: AgentDef = serde_yaml::from_str(
|
||||
"name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
// No `detect`: falls back to the first token of run.
|
||||
let b: AgentDef =
|
||||
serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap();
|
||||
assert_eq!(b.detect_token().as_deref(), Some("python"));
|
||||
assert_eq!(b.first_token().as_deref(), Some("python"));
|
||||
// Plain binary command is unchanged.
|
||||
let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap();
|
||||
assert_eq!(c.detect_token().as_deref(), Some("claude"));
|
||||
// The embedded catalog must stay valid with the new field.
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap();
|
||||
assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap();
|
||||
assert_eq!(nano.detect_token().as_deref(), Some("nanobot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_paths() {
|
||||
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
|
||||
|
||||
@@ -48,6 +48,13 @@ pub enum EventKind {
|
||||
Alert,
|
||||
/// One benchmark run of 'am lab' (issue #62).
|
||||
Lab,
|
||||
/// LLM provider registry change: add, remove, default (issue #88).
|
||||
Provider,
|
||||
/// Sandbox refusal journalized for audit (issue #79).
|
||||
Sandbox,
|
||||
/// am ai — Shell AI action: generated, classified and/or executed
|
||||
/// (issues #96 #97).
|
||||
ShellAi,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
@@ -73,6 +80,9 @@ impl EventKind {
|
||||
EventKind::Cost => "cost",
|
||||
EventKind::Alert => "alert",
|
||||
EventKind::Lab => "lab",
|
||||
EventKind::Provider => "provider",
|
||||
EventKind::Sandbox => "sandbox",
|
||||
EventKind::ShellAi => "shell_ai",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+195
-8
@@ -156,6 +156,24 @@ const START_FLAGS: &[HelpFlag] = &[
|
||||
value: "",
|
||||
desc: "Run inside the agent's container profile (issue #58)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--no-sandbox",
|
||||
value: "",
|
||||
desc: "Skip the agent's sandbox profile (issue #79)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--model",
|
||||
value: "MODEL",
|
||||
desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--provider",
|
||||
value: "PROVIDER",
|
||||
desc: "Provider of the registry to use (base_url + keyring token + model) (issue #92)",
|
||||
},
|
||||
];
|
||||
|
||||
pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
@@ -664,18 +682,54 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "secret",
|
||||
category: "Commands",
|
||||
usage: "secret {flags} <set|unset|list>",
|
||||
about: "Manage secrets in the OS keyring (values never shown or logged).",
|
||||
about: "Manage secrets in the OS keyring (values never shown or logged). Secrets are scoped to an agent (agent/NAME) or to a provider (providers/<p>/<VAR>, shared by every agent of that provider, issue #89).",
|
||||
search_terms: &["keyring", "token", "password"],
|
||||
flags: &[],
|
||||
subcommands: &[
|
||||
("set", "Store a secret for an agent"),
|
||||
("set", "Store a secret for an agent or a provider"),
|
||||
("unset", "Remove a secret"),
|
||||
],
|
||||
parameters: &[
|
||||
HelpParam { name: "--agent", typ: "string", desc: "Agent the secret belongs to" },
|
||||
HelpParam { name: "--provider", typ: "string", desc: "Provider the secret belongs to (issue #89)" },
|
||||
HelpParam { name: "--value", typ: "string", desc: "Secret value (never logged)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." },
|
||||
HelpExample { desc: "Store a provider token.", code: "secret set api_key --provider openai --value sk-... (or: providers set-token openai)" },
|
||||
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "providers",
|
||||
category: "Commands",
|
||||
usage: "providers {flags} <list|show|add|remove|default>",
|
||||
about: "Manage the LLM provider registry: base URLs, models, the default model and the default provider (issue #88). Tokens are never stored here — they live in the OS keyring under providers/<name>/api_key (issue #89).",
|
||||
search_terms: &["provider", "token", "registry", "llm", "model", "api", "keyring"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--base-url", value: "URL", desc: "Base URL of the provider API (add)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model used by default for this provider (add)" },
|
||||
HelpFlag { short: "", long: "--models", value: "MODELS", desc: "Comma-separated model list (add)" },
|
||||
HelpFlag { short: "", long: "--value", value: "VALUE", desc: "Token value (set-token; never logged)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("list", "List the registered providers (default starred)"),
|
||||
("show", "Show one provider in detail"),
|
||||
("add", "Register or update a provider"),
|
||||
("remove", "Remove a provider"),
|
||||
("default", "Set the default provider"),
|
||||
("set-token", "Store the provider token in the OS keyring (issue #89)"),
|
||||
("token", "Check whether a provider token exists (value never shown)"),
|
||||
],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." },
|
||||
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
|
||||
HelpExample { desc: "List the providers.", code: "providers" },
|
||||
HelpExample { desc: "Register DeepSeek.", code: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat --models deepseek-chat,deepseek-reasoner" },
|
||||
HelpExample { desc: "Store its shared token.", code: "providers set-token deepseek --value sk-..." },
|
||||
HelpExample { desc: "Make it the default.", code: "providers default deepseek" },
|
||||
HelpExample { desc: "Machine-readable list.", code: "providers --json" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -761,11 +815,14 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "install",
|
||||
category: "Commands",
|
||||
usage: "install {flags} <agent>",
|
||||
about: "Install an agent and its dependencies.",
|
||||
search_terms: &["add", "setup"],
|
||||
about: "Install an agent and its dependencies, and resolve its LLM provider + model (issue #90): --provider > agent.provider > settings.default_provider; --model > agent.model > provider.default_model.",
|
||||
search_terms: &["add", "setup", "provider", "model"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--method", value: "METHOD", desc: "Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)" },
|
||||
HelpFlag { short: "", long: "--force", value: "", desc: "Reinstall even if already installed" },
|
||||
HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider to configure (default: the agent's provider, else settings.default_provider)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to configure (default: the provider's default model)" },
|
||||
HelpFlag { short: "", long: "--no-config", value: "", desc: "Skip the post-install provider configuration (issue #91)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
@@ -776,6 +833,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Install an agent with its dependencies.", code: "install claude-code" },
|
||||
HelpExample { desc: "Pick a specific install method.", code: "install smelt --method uv" },
|
||||
HelpExample { desc: "Reinstall over an existing install.", code: "install claude-code --force" },
|
||||
HelpExample { desc: "Force a provider and a model.", code: "install agentty --provider deepseek --model deepseek-reasoner" },
|
||||
HelpExample { desc: "Preview the resolved provider/model without installing.", code: "install agentty --dry-run" },
|
||||
HelpExample { desc: "Install without any post-install configuration.", code: "install agentty --no-config" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -797,6 +857,123 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "setup",
|
||||
category: "Commands",
|
||||
usage: "setup {flags}",
|
||||
about: "Wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat et génération des rôles copilot am-* (épique v1.1.0). Ré-exécutable à tout moment (mode revoir).",
|
||||
search_terms: &["wizard", "onboarding", "provider", "token", "setup", "configurer", "premier"],
|
||||
flags: &[
|
||||
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Mode non-interactif : garde les valeurs actuelles (provider/modèle), installe aichat, régénère les rôles" },
|
||||
HelpFlag { short: "", long: "--roles", value: "", desc: "Régénérer uniquement les rôles copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Premier lancement (proposé aussi par am ai / le banner REPL)", code: "am setup" },
|
||||
HelpExample { desc: "Régénérer les rôles après une mise à jour", code: "am setup --roles" },
|
||||
HelpExample { desc: "Non-interactif (scripts)", code: "am setup --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "ask",
|
||||
category: "Commands",
|
||||
usage: "ask {flags} <demande...>",
|
||||
about: "Traduit une demande en langage naturel en commande(s) am : règles locales hors-ligne, puis raffinement LLM optionnel via le registre providers, cache des traductions, confirmation avant exécution (issue #78).",
|
||||
search_terms: &["nlp", "natural", "language", "traduire", "langage"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--yes", value: "", desc: "Skip the confirmation prompt" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "demande", typ: "string", desc: "La demande en langage naturel, ex: «installe claude et lance-le»" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Installer puis lancer un agent.", code: "ask installe claude et lance-le" },
|
||||
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "ai",
|
||||
category: "Commands",
|
||||
usage: "ai {flags} <prompt...>",
|
||||
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --code — jamais exécutée par aichat), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
|
||||
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
|
||||
flags: &[
|
||||
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
|
||||
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
|
||||
HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" },
|
||||
HelpFlag { short: "", long: "--role", value: "ROLE", desc: "Rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator — générés par am setup)" },
|
||||
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" },
|
||||
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "prompt", typ: "string", desc: "La demande en langage naturel, ex: «liste les fichiers JSON du dossier courant»" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Mode conversationnel (défaut).", code: "ai liste tous les fichiers JSON et extrait les clés uniques" },
|
||||
HelpExample { desc: "Générer la commande sans l'exécuter (dry-run par défaut).", code: "ai --exec \"compresse les fichiers JSON en un zip\"" },
|
||||
HelpExample { desc: "Exécuter après confirmation explicite.", code: "ai --exec \"supprime les fichiers .tmp\" --yes" },
|
||||
HelpExample { desc: "Passer un dossier en contexte.", code: "ai --files ./data \"résume les données de ces fichiers\"" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "registry",
|
||||
category: "Commands",
|
||||
usage: "registry <publish|search|install|list>",
|
||||
about: "Registre communautaire (issue #77) : publier un catalogue + manifeste (source, version, auteur, sha256) sur Gitea, chercher des agents dans les sources enregistrées, et installer un catalogue avec validation du checksum et décision de confiance explicite.",
|
||||
search_terms: &["community", "publish", "manifest", "sha256", "confiance"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--source", value: "URL", desc: "URL publique du catalogue publié (publish)" },
|
||||
HelpFlag { short: "", long: "--author", value: "NOM", desc: "Auteur enregistré dans le manifeste (publish)" },
|
||||
HelpFlag { short: "", long: "--version", value: "V", desc: "Version du catalogue, défaut 1.0.0 (publish)" },
|
||||
HelpFlag { short: "", long: "--yes", value: "", desc: "Accepter la source sans confirmation (install)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("registry publish", "prépare le manifeste (sha256) à côté du catalogue"),
|
||||
("registry install", "installe avec validation checksum + confiance"),
|
||||
],
|
||||
parameters: &[
|
||||
HelpParam { name: "mot", typ: "string", desc: "Mot-clé de recherche (nom, description ou tag d'agent)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Préparer un catalogue pour publication.", code: "registry publish am-catalog.yaml --source https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --author bruno" },
|
||||
HelpExample { desc: "Chercher un agent dans les sources enregistrées.", code: "registry search claude" },
|
||||
HelpExample { desc: "Installer un catalogue en faisant confiance à la source.", code: "registry install https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "serve",
|
||||
category: "Commands",
|
||||
usage: "serve --token <TOKEN> [--host HOST] [--port PORT] [--rate-limit N]",
|
||||
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (issue #80) : token obligatoire sur chaque requête, contrats --json réutilisés (stats, sessions, ps, run, start, stop, ask), WebSocket diffusant les événements du journal en temps réel, rate limiting par IP. TLS : placez le serveur derrière un reverse proxy (caddy/nginx).",
|
||||
search_terms: &["api", "http", "websocket", "token", "remote", "serveur"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--token", value: "TOKEN", desc: "Bearer token requis sur chaque requête (obligatoire)" },
|
||||
HelpFlag { short: "", long: "--host", value: "HOST", desc: "Adresse d'écoute (défaut 127.0.0.1)" },
|
||||
HelpFlag { short: "", long: "--port", value: "PORT", desc: "Port d'écoute (défaut 8080)" },
|
||||
HelpFlag { short: "", long: "--rate-limit", value: "N", desc: "Budget de requêtes par minute et par IP (défaut 120)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("/api/health", "GET — {ok, version}"),
|
||||
("/api/stats", "GET — am stats --json"),
|
||||
("/api/run", "POST {agent, model?, provider?, args?} — am run --json"),
|
||||
("/ws", "GET — WebSocket : événements du journal en temps réel"),
|
||||
],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Démarrer le serveur.", code: "serve --token MonTokenSecret --port 8080" },
|
||||
HelpExample { desc: "Appeler une route (depuis une autre machine).", code: "curl -H \"Authorization: Bearer MonTokenSecret\" http://serveur:8080/api/stats" },
|
||||
HelpExample { desc: "Lancer un agent à distance.", code: "curl -X POST -H \"Authorization: Bearer MonTokenSecret\" -d '{\"agent\":\"claude-code\"}' http://serveur:8080/api/run" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "start",
|
||||
category: "Commands",
|
||||
@@ -869,7 +1046,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
about: "Run the agent command directly with the given arguments (no process management).",
|
||||
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Local model to use (issue #71)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)" },
|
||||
HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider registry override at launch (issue #92)" },
|
||||
HelpFlag { short: "", long: "--no-sandbox", value: "", desc: "Skip the agent's sandbox profile for this run (issue #79)" },
|
||||
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
@@ -880,6 +1059,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Run an agent command directly.", code: "run claude-code" },
|
||||
HelpExample { desc: "Use a provider of the registry.", code: "run claude-code --provider anthropic --model claude-sonnet-4" },
|
||||
HelpExample { desc: "Pass --help through to the agent (after --).", code: "run claude-code -- --help" },
|
||||
],
|
||||
},
|
||||
@@ -2131,7 +2311,14 @@ mod tests {
|
||||
assert!(text.contains("build_target"));
|
||||
assert!(text.contains("allocator"));
|
||||
assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(info.major, 0);
|
||||
// The major/minor version follows Cargo.toml: parse instead of pinning.
|
||||
let major: u64 = env!("CARGO_PKG_VERSION")
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(info.major, major);
|
||||
// The minor version follows Cargo.toml: parse it instead of pinning it.
|
||||
let minor: u64 = env!("CARGO_PKG_VERSION")
|
||||
.split('.')
|
||||
|
||||
+71
-1
@@ -159,7 +159,68 @@ pub const CATALOG: &[(&str, &str)] = &[
|
||||
("date", "date"),
|
||||
("début", "start"),
|
||||
("fin", "end"),
|
||||
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
|
||||
("am ask est désactivé — settings.ask.enabled: true pour l'activer", "am ask is disabled — set settings.ask.enabled: true to enable it"),
|
||||
("je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»", "I did not understand — examples: «installe claude et lance-le», «liste les agents», «arrête codex»"),
|
||||
("exécuter ces commandes ?", "run these commands?"),
|
||||
("annulé", "cancelled"),
|
||||
("Exécuter ?", "Execute?"),
|
||||
("dry-run : commande non exécutée", "dry-run: command not executed"),
|
||||
("politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes", "safety policy (dry-run by default): command not executed — use --yes"),
|
||||
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
|
||||
("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"),
|
||||
("usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai <request> — e.g. ai --exec \"compress the JSON files\""),
|
||||
("am n'est pas configuré — lancez am setup (provider + token)", "am is not configured — run am setup (provider + token)"),
|
||||
("Configuration d'agent-manager (am setup)", "agent-manager configuration (am setup)"),
|
||||
("Le token est stocké dans le trousseau OS — jamais en clair.", "The token is stored in the OS keyring — never in clear."),
|
||||
("Installer le moteur IA (aichat) ?", "Install the AI engine (aichat)?"),
|
||||
("Installation d'aichat…", "Installing aichat…"),
|
||||
("aichat est manquant. Installer ?", "aichat is missing. Install it?"),
|
||||
("installez aichat: am install aichat — puis réessayez", "install aichat: am install aichat — then retry"),
|
||||
("Rôles copilot am-* générés", "am-* copilot roles generated"),
|
||||
("Config aichat créée (provider + modèle)", "aichat config created (provider + model)"),
|
||||
("am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"", "am is configured — try: am ai \"...\" or am ai --exec \"...\""),
|
||||
("rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)", "unknown role '{}' — generate the roles with: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
|
||||
("rôle 'am-copilot' introuvable — lancez am setup --roles", "role 'am-copilot' not found — run am setup --roles"),
|
||||
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
|
||||
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
|
||||
("installation refusée — source non fiable", "installation refused — untrusted source"),
|
||||
("usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list", "usage: registry publish <catalog.yaml> --source <url> | registry search <query> | registry install <url> | registry list"),
|
||||
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest un agent pour du Python"),
|
||||
// ---- providers (#88) ---------------------------------------------------
|
||||
("aucun provider enregistré — voir: am providers add <nom> --base-url <url>", "no providers registered — see: am providers add <name> --base-url <url>"),
|
||||
("provider '{}' inconnu", "unknown provider '{}'"),
|
||||
("nom de provider invalide '{}' — attendu un slug [a-z0-9_-]", "invalid provider name '{}' — expected a slug [a-z0-9_-]"),
|
||||
("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]", "usage: providers add <name> --base-url <url> [--model <m>] [--models m1,m2]"),
|
||||
("provider '{}' mis à jour", "provider '{}' updated"),
|
||||
("provider '{}' ajouté", "provider '{}' added"),
|
||||
("positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)", "store the token in the keyring: am providers set-token {} (issue #89, coming)"),
|
||||
("provider '{}' supprimé", "provider '{}' removed"),
|
||||
("le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", "the default provider '{}' cannot be removed — change the default provider first"),
|
||||
("provider par défaut : {}", "default provider: {}"),
|
||||
("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
|
||||
("token stocké pour le provider '{}'", "token stored for provider '{}'"),
|
||||
("token présent pour le provider '{}'", "token present for provider '{}'"),
|
||||
("aucun token pour le provider '{}' — voir: am providers set-token {}", "no token for provider '{}' — see: am providers set-token {}"),
|
||||
("secret '{}' stocké pour {}", "secret '{}' stored for {}"),
|
||||
("secret '{}' supprimé pour {}", "secret '{}' removed for {}"),
|
||||
("secret '{}' inexistant pour {}", "secret '{}' does not exist for {}"),
|
||||
("--no-config : aucune configuration post-install", "--no-config: no post-install configuration"),
|
||||
("{} ne prend aucune configuration (install.configurable: false)", "{} takes no configuration (install.configurable: false)"),
|
||||
("provider '{}' inconnu — providers enregistrés: {}", "unknown provider '{}' — registered providers: {}"),
|
||||
("provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url <url>", "provider '{}' (declared by {}) is not registered — see: am providers add {} --base-url <url>"),
|
||||
("aucun provider configuré — voir: am providers add <nom> --base-url <url>", "no provider configured — see: am providers add <name> --base-url <url>"),
|
||||
("modèle '{}' absent de la liste du provider '{}' — modèles: {}", "model '{}' is not in provider '{}' model list — models: {}"),
|
||||
("configuré avec le provider {} (modèle {})", "configured with provider {} (model {})"),
|
||||
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"),
|
||||
("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"),
|
||||
("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
|
||||
("Configuration manuelle requise — à exécuter :", "Manual setup required — run:"),
|
||||
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
|
||||
("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"),
|
||||
("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"),
|
||||
("clé '{}' inconnue dans config.files de {}", "unknown key '{}' in config.files of {}"),
|
||||
("config écrite: {}", "config written: {}"),
|
||||
("usage: secret set <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>", "usage: secret set <name> --agent <agent> --value <value> | secret set <name> --provider <provider> --value <value>"),
|
||||
("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"),
|
||||
("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"),
|
||||
("vouliez-vous dire", "did you mean"),
|
||||
@@ -388,6 +449,15 @@ pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a message for a GIVEN locale (deterministic — follows the app's
|
||||
/// resolved language instead of the process-global locale).
|
||||
pub fn tr_fmt_in<'a>(lang: Lang, key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
match lang {
|
||||
Lang::Fr => fmt_args(key, args),
|
||||
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `{}` placeholders (positional) with the Display of each argument.
|
||||
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
let mut out = String::with_capacity(template.len() + 16);
|
||||
|
||||
@@ -98,7 +98,9 @@ pub fn sh(
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<RunOutput> {
|
||||
let out = runner.run(cmd, args, env, cwd, true)?;
|
||||
// Installs are legitimately long (npm/cargo/go/... can take minutes):
|
||||
// no probe timeout here, only run() (doctor/version probes) is bounded.
|
||||
let out = runner.run_untimed(cmd, args, env, cwd, true)?;
|
||||
if out.code != 0 {
|
||||
let mut detail = out.stderr.trim().to_string();
|
||||
if detail.is_empty() {
|
||||
|
||||
+16
-1
@@ -12,6 +12,11 @@
|
||||
//! * commands — one module per CLI command.
|
||||
|
||||
pub mod app;
|
||||
pub mod agent_config;
|
||||
pub mod ask;
|
||||
pub mod roles;
|
||||
pub mod setup;
|
||||
pub mod shell_ai;
|
||||
pub mod automation;
|
||||
pub mod backup;
|
||||
pub mod catalog;
|
||||
@@ -37,16 +42,21 @@ pub mod models;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
pub mod probe;
|
||||
pub mod providers;
|
||||
pub mod projects;
|
||||
pub mod ps;
|
||||
pub mod process;
|
||||
pub mod registry;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod sandbox;
|
||||
pub mod secrets;
|
||||
pub mod serve;
|
||||
pub mod sessions;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod sync;
|
||||
pub mod telemetry;
|
||||
pub mod playbook;
|
||||
pub mod plugins;
|
||||
pub mod tables;
|
||||
@@ -103,8 +113,13 @@ fn real_main() -> i32 {
|
||||
app.log.verbose(&format!("session retention skipped: {e:#}"));
|
||||
}
|
||||
match commands::execute(&app) {
|
||||
Ok(code) => code,
|
||||
Ok(code) => {
|
||||
// Issue #76: batched anonymous telemetry, fire-and-forget.
|
||||
crate::telemetry::maybe_flush(&app);
|
||||
code
|
||||
}
|
||||
Err(err) => {
|
||||
crate::telemetry::maybe_flush(&app);
|
||||
if app.json() {
|
||||
let payload = serde_json::json!({
|
||||
"error": format!("{:#}", err),
|
||||
|
||||
+56
-19
@@ -30,7 +30,6 @@ impl ExternalInfo {
|
||||
/// Detect the version of an external binary (--version), bounded by a
|
||||
/// timeout so a misbehaving agent can never hang the whole listing.
|
||||
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
|
||||
let mut cmd = std::process::Command::new(&prog);
|
||||
cmd.args(&prefix)
|
||||
@@ -41,6 +40,24 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
let Ok(mut child) = cmd.spawn() else {
|
||||
return None;
|
||||
};
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let mut out = String::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut err = String::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut err);
|
||||
}
|
||||
err
|
||||
});
|
||||
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
|
||||
Ok(Some(s)) => Some(s),
|
||||
@@ -51,22 +68,14 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let Some(status) = status else {
|
||||
return None;
|
||||
};
|
||||
let stdout = stdout_handle.join().unwrap_or_default();
|
||||
let stderr = stderr_handle.join().unwrap_or_default();
|
||||
|
||||
let status = status?;
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut o) = child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut stdout);
|
||||
}
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
let _ = e.read_to_string(&mut stderr);
|
||||
}
|
||||
let combined = format!("{stdout}
|
||||
{stderr}");
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
crate::version::find_version(&combined)
|
||||
}
|
||||
|
||||
@@ -79,6 +88,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 +112,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 +197,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 +216,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
|
||||
}
|
||||
}
|
||||
cache.path_hash = current_hash;
|
||||
cache.token_hash = current_tokens;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
@@ -195,7 +232,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 +248,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) {
|
||||
|
||||
+13
-8
@@ -17,14 +17,19 @@ pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
|
||||
pub fn is_running(pid: u32) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let out = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.any(|w| w == pid.to_string()),
|
||||
Err(_) => false,
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, STILL_ACTIVE};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if handle == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut code: u32 = 0;
|
||||
let ok = GetExitCodeProcess(handle, &mut code);
|
||||
CloseHandle(handle);
|
||||
ok != 0 && code == STILL_ACTIVE as u32
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! LLM provider registry (issue #88): resolution helpers over the
|
||||
//! `settings.providers` map — the default provider, its default model and
|
||||
//! name validation. Tokens are deliberately out of scope here: they live in
|
||||
//! the OS keyring under `providers/<name>/api_key` (issue #89).
|
||||
//!
|
||||
//! The install-time configuration (issues #90, #92) will resolve the
|
||||
//! provider/model of an agent through these helpers.
|
||||
|
||||
use crate::config::{Config, ProviderDef};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Empty provider map shared by the helpers below (never mutated).
|
||||
static EMPTY: BTreeMap<String, Option<ProviderDef>> = BTreeMap::new();
|
||||
|
||||
/// Every provider of the effective configuration (empty when unset).
|
||||
/// `None` values are the YAML null-deletion markers and are skipped.
|
||||
pub fn all(config: &Config) -> &BTreeMap<String, Option<ProviderDef>> {
|
||||
config.settings.providers.as_ref().unwrap_or(&EMPTY)
|
||||
}
|
||||
|
||||
/// The providers actually defined (null markers filtered out).
|
||||
pub fn defined(config: &Config) -> impl Iterator<Item = (&String, &ProviderDef)> {
|
||||
all(config)
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.as_ref().map(|def| (k, def)))
|
||||
}
|
||||
|
||||
/// Look up one provider by name.
|
||||
pub fn get<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderDef> {
|
||||
config.settings.provider(name)
|
||||
}
|
||||
|
||||
/// Name of the default provider, when set and defined.
|
||||
pub fn default_name(config: &Config) -> Option<&str> {
|
||||
let (name, _) = config.settings.default_provider()?;
|
||||
Some(name.as_str())
|
||||
}
|
||||
|
||||
/// The default provider entry (name + definition).
|
||||
pub fn default(config: &Config) -> Option<(&String, &ProviderDef)> {
|
||||
config.settings.default_provider()
|
||||
}
|
||||
|
||||
/// Model to use for a provider: the explicit model when given, otherwise
|
||||
/// the provider's default model.
|
||||
pub fn model_for<'a>(provider: &'a ProviderDef, explicit: Option<&'a str>) -> Option<&'a str> {
|
||||
explicit
|
||||
.filter(|m| !m.is_empty())
|
||||
.or(provider.default_model.as_deref())
|
||||
}
|
||||
|
||||
/// Sorted provider names (for error messages listing the registry).
|
||||
pub fn names(config: &Config) -> Vec<String> {
|
||||
let mut v: Vec<String> = defined(config).map(|(k, _)| k.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Resolve the provider + model of an agent at install time (issue #90):
|
||||
/// `flag --provider` > `agent.provider` > `settings.default_provider` ;
|
||||
/// `flag --model` > `agent.model` > `provider.default_model`.
|
||||
/// Returns None when no provider is configured at all.
|
||||
pub fn resolve_for<'a>(
|
||||
config: &'a Config,
|
||||
agent_provider: Option<&'a str>,
|
||||
agent_model: Option<&'a str>,
|
||||
flag_provider: Option<&'a str>,
|
||||
flag_model: Option<&'a str>,
|
||||
) -> Option<(&'a str, &'a ProviderDef, Option<&'a str>)> {
|
||||
let provider_name = flag_provider
|
||||
.filter(|p| !p.is_empty())
|
||||
.or(agent_provider.filter(|p| !p.is_empty()))
|
||||
.or_else(|| default_name(config))?;
|
||||
let def = get(config, provider_name)?;
|
||||
let model = model_for(
|
||||
def,
|
||||
flag_model
|
||||
.filter(|m| !m.is_empty())
|
||||
.or(agent_model.filter(|m| !m.is_empty())),
|
||||
);
|
||||
Some((provider_name, def, model))
|
||||
}
|
||||
|
||||
/// Provider names accept the same slug rule as agent names: [a-z0-9_-].
|
||||
pub fn is_valid_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Config, ProviderDef};
|
||||
|
||||
fn config_with() -> Config {
|
||||
let mut c = Config::default();
|
||||
c.settings.default_provider = Some("openai".to_string());
|
||||
let mut providers = BTreeMap::new();
|
||||
providers.insert(
|
||||
"openai".to_string(),
|
||||
Some(ProviderDef {
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
default_model: Some("gpt-5.2".to_string()),
|
||||
models: vec!["gpt-5.2".to_string(), "gpt-5.1-mini".to_string()],
|
||||
}),
|
||||
);
|
||||
providers.insert(
|
||||
"deepseek".to_string(),
|
||||
Some(ProviderDef {
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
default_model: None,
|
||||
models: vec!["deepseek-chat".to_string()],
|
||||
}),
|
||||
);
|
||||
c.settings.providers = Some(providers);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_the_default_provider() {
|
||||
let c = config_with();
|
||||
let (name, def) = default(&c).expect("default provider");
|
||||
assert_eq!(name, "openai");
|
||||
assert_eq!(def.base_url, "https://api.openai.com/v1");
|
||||
assert_eq!(default_name(&c), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_wins_over_the_default() {
|
||||
let c = config_with();
|
||||
let def = get(&c, "deepseek").unwrap();
|
||||
assert_eq!(model_for(def, None), None);
|
||||
assert_eq!(model_for(def, Some("deepseek-reasoner")), Some("deepseek-reasoner"));
|
||||
let openai = get(&c, "openai").unwrap();
|
||||
assert_eq!(model_for(openai, None), Some("gpt-5.2"));
|
||||
assert_eq!(model_for(openai, Some("gpt-5.1-mini")), Some("gpt-5.1-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_missing_provider_is_none() {
|
||||
let mut c = config_with();
|
||||
c.settings.default_provider = Some("nope".to_string());
|
||||
assert!(default(&c).is_none());
|
||||
assert!(default_name(&c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation_follows_the_slug_rule() {
|
||||
assert!(is_valid_name("openai"));
|
||||
assert!(is_valid_name("deep-seek_2"));
|
||||
assert!(!is_valid_name("OpenAI"));
|
||||
assert!(!is_valid_name(""));
|
||||
assert!(!is_valid_name("has space"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_for_flag_beats_agent_beats_default() {
|
||||
let c = config_with();
|
||||
// 1. Rien de précisé → default_provider (openai) + son default_model.
|
||||
let (p, _, m) = resolve_for(&c, None, None, None, None).unwrap();
|
||||
assert_eq!(p, "openai");
|
||||
assert_eq!(m, Some("gpt-5.2"));
|
||||
// 2. Le provider déclaré par l'agent gagne sur le défaut.
|
||||
let (p, _, m) = resolve_for(&c, Some("deepseek"), None, None, None).unwrap();
|
||||
assert_eq!(p, "deepseek");
|
||||
assert_eq!(m, None, "deepseek n'a pas de default_model");
|
||||
// 3. Le flag --provider gagne sur tout.
|
||||
let (p, _, m) = resolve_for(&c, Some("openai"), None, Some("deepseek"), None).unwrap();
|
||||
assert_eq!(p, "deepseek");
|
||||
// 4. --model flag > agent.model > default_model.
|
||||
let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, None).unwrap();
|
||||
assert_eq!(m, Some("o4-mini"));
|
||||
let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, Some("gpt-5.1-mini")).unwrap();
|
||||
assert_eq!(m, Some("gpt-5.1-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_for_none_without_any_provider() {
|
||||
let mut c = config_with();
|
||||
c.settings.default_provider = None;
|
||||
c.settings.providers = None;
|
||||
assert!(resolve_for(&c, None, None, None, None).is_none());
|
||||
// Un agent qui déclare son provider résout même sans défaut.
|
||||
assert!(resolve_for(&c, Some("openai"), None, None, None).is_none());
|
||||
let mut c2 = config_with();
|
||||
c2.settings.default_provider = None;
|
||||
assert_eq!(resolve_for(&c2, Some("openai"), None, None, None).unwrap().0, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_sorted() {
|
||||
let c = config_with();
|
||||
assert_eq!(names(&c), vec!["deepseek".to_string(), "openai".to_string()]);
|
||||
}
|
||||
}
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
//! Community registry (issue #77): publish, search and install agent
|
||||
//! catalogs hosted on Gitea.
|
||||
//!
|
||||
//! A published catalog ships with a manifest (`am-manifest.json`) recording
|
||||
//! source, version, author, agent count and the sha256 of the catalog file.
|
||||
//! `am registry install` validates the checksum and requires an explicit
|
||||
//! trust decision for unknown sources — an untrusted source is refused.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Name of the manifest file, expected next to the catalog file.
|
||||
pub const MANIFEST_NAME: &str = "am-manifest.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub schema: u32,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: String,
|
||||
/// Public URL of the catalog file this manifest describes.
|
||||
pub source: String,
|
||||
pub agents_count: usize,
|
||||
/// Lowercase hex sha256 of the catalog file content.
|
||||
pub sha256: String,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
/// sha256 of some bytes, lowercase hex.
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let digest = hasher.finalize();
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The manifest URL living next to a catalog URL: same base, file name
|
||||
/// replaced by `am-manifest.json` (or appended when the URL is a directory).
|
||||
pub fn manifest_url_of(catalog_url: &str) -> String {
|
||||
let trimmed = catalog_url.trim_end_matches('/');
|
||||
let last = trimmed.rsplit('/').next().unwrap_or("");
|
||||
if last.contains('.') && !last.contains('?') {
|
||||
// File URL: replace the file name.
|
||||
let base = trimmed.trim_end_matches(last);
|
||||
format!("{base}{MANIFEST_NAME}")
|
||||
} else {
|
||||
// Directory URL: append.
|
||||
format!("{trimmed}/{MANIFEST_NAME}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and write the manifest next to a catalog file (dry-run safe).
|
||||
pub fn publish(
|
||||
app: &App,
|
||||
catalog_path: &Path,
|
||||
source: Option<&str>,
|
||||
author: Option<&str>,
|
||||
version: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
let text = std::fs::read_to_string(catalog_path)
|
||||
.map_err(|e| anyhow!("cannot read {}: {e}", catalog_path.display()))?;
|
||||
let remote = crate::catalog_remote::parse_catalog(&text)?;
|
||||
if remote.agents.is_empty() {
|
||||
anyhow::bail!("the catalog defines no agents — refusing to publish");
|
||||
}
|
||||
let Some(source) = source.filter(|s| !s.trim().is_empty()) else {
|
||||
anyhow::bail!(
|
||||
"--source <url> is required: the public URL of the catalog once pushed \
|
||||
(e.g. https://git.dracodev.net/<user>/<repo>/raw/branch/main/am-catalog.yaml)"
|
||||
);
|
||||
};
|
||||
let author = author
|
||||
.filter(|a| !a.trim().is_empty())
|
||||
.or_else(|| {
|
||||
app.config
|
||||
.settings
|
||||
.registry
|
||||
.as_ref()
|
||||
.and_then(|r| r.author.as_deref())
|
||||
})
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let name = catalog_path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "catalog".to_string());
|
||||
let manifest = Manifest {
|
||||
schema: 1,
|
||||
name,
|
||||
version: version.unwrap_or("1.0.0").to_string(),
|
||||
author,
|
||||
source: source.to_string(),
|
||||
agents_count: remote.agents.len(),
|
||||
sha256: sha256_hex(text.as_bytes()),
|
||||
published_at: crate::installers::now_rfc3339(),
|
||||
};
|
||||
let manifest_path = catalog_path.with_file_name(MANIFEST_NAME);
|
||||
if app.json() {
|
||||
print!("{}", serde_json::to_string_pretty(&manifest)?);
|
||||
println!();
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would write {} ({} agents, sha256 {})",
|
||||
manifest_path.display(),
|
||||
manifest.agents_count,
|
||||
&manifest.sha256[..12.min(manifest.sha256.len())]
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)
|
||||
.map_err(|e| anyhow!("cannot write {}: {e}", manifest_path.display()))?;
|
||||
app.log.success(&format!(
|
||||
"manifest written: {} ({} agents, sha256 {})",
|
||||
manifest_path.display(),
|
||||
manifest.agents_count,
|
||||
&manifest.sha256[..12]
|
||||
));
|
||||
app.log.info(&format!(
|
||||
"push both files to your Gitea repo, then register/install with:\n am registry install {source}"
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Fetch + validate the manifest of a catalog URL.
|
||||
pub fn fetch_manifest(app: &App, catalog_url: &str) -> Result<Manifest> {
|
||||
let murl = manifest_url_of(catalog_url);
|
||||
let text = crate::catalog_remote::fetch(app, &murl, true)?;
|
||||
let manifest: Manifest = serde_json::from_str(&text)
|
||||
.map_err(|e| anyhow!("invalid manifest at {murl}: {e}"))?;
|
||||
if manifest.sha256.len() != 64 {
|
||||
anyhow::bail!("manifest at {murl} has an invalid sha256");
|
||||
}
|
||||
if manifest.source != catalog_url {
|
||||
anyhow::bail!(
|
||||
"manifest source mismatch: declares {} but requested {} — refused",
|
||||
manifest.source,
|
||||
catalog_url
|
||||
);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Registered sources of the community registry.
|
||||
pub fn sources(app: &App) -> Vec<String> {
|
||||
app.config
|
||||
.settings
|
||||
.registry
|
||||
.as_ref()
|
||||
.map(|r| r.sources.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// am registry search <query>: probe every registered source, list the
|
||||
/// agents matching the query (name, description, tags).
|
||||
pub fn search(app: &App, query: &str) -> Result<i32> {
|
||||
let sources = sources(app);
|
||||
if sources.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"aucune source enregistrée — voir settings.registry.sources ou: am registry install <url>",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let q = query.trim().to_lowercase();
|
||||
let mut found = 0usize;
|
||||
for url in &sources {
|
||||
let Ok(manifest) = fetch_manifest(app, url) else {
|
||||
app.log
|
||||
.verbose(&format!("registry source {url}: manifest unavailable"));
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = crate::catalog_remote::fetch(app, url, true) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(remote) = crate::catalog_remote::parse_catalog(&text) else {
|
||||
continue;
|
||||
};
|
||||
let matches: Vec<&crate::config::AgentDef> = remote
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
q.is_empty()
|
||||
|| a.name.to_lowercase().contains(&q)
|
||||
|| a.description.as_deref().unwrap_or("").to_lowercase().contains(&q)
|
||||
|| a.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||
})
|
||||
.collect();
|
||||
if matches.is_empty() {
|
||||
continue;
|
||||
}
|
||||
found += matches.len();
|
||||
app.log.info(&format!(
|
||||
"{} v{} par {} ({})",
|
||||
manifest.name, manifest.version, manifest.author, url
|
||||
));
|
||||
for a in &matches {
|
||||
println!(" {} — {}", a.name, a.description.as_deref().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
app.log.info(&format!("aucun agent ne correspond à «{query}»"));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am registry install <url>: manifest + checksum + trust, then register the
|
||||
/// catalog as a remote include (same mechanism as `am catalog add`).
|
||||
pub fn install(app: &App, url: &str, yes: bool) -> Result<i32> {
|
||||
crate::download::validate_url(url)?;
|
||||
app.log.info(&format!("fetching manifest: {}", manifest_url_of(url)));
|
||||
let manifest = fetch_manifest(app, url)?;
|
||||
app.log.info(&format!("fetching catalog: {url}"));
|
||||
let text = crate::catalog_remote::fetch(app, url, true)?;
|
||||
let actual = sha256_hex(text.as_bytes());
|
||||
if actual != manifest.sha256 {
|
||||
anyhow::bail!(
|
||||
"checksum mismatch — expected {} got {} (source non fiable, refusée)",
|
||||
&manifest.sha256[..12],
|
||||
&actual[..12]
|
||||
);
|
||||
}
|
||||
let remote = crate::catalog_remote::parse_catalog(&text)?;
|
||||
if remote.agents.is_empty() {
|
||||
anyhow::bail!("the catalog defines no agents — refusing to install");
|
||||
}
|
||||
// Trust: a source already registered (settings.registry.sources or an
|
||||
// active include) is trusted; anything else needs an explicit decision.
|
||||
let known = sources(app).iter().any(|s| s == url)
|
||||
|| crate::commands::config_cmd::user_includes(app)
|
||||
.iter()
|
||||
.any(|i| i == url);
|
||||
if !known && !yes {
|
||||
if !app.confirm(&format!(
|
||||
"source non fiable : {} (auteur {}, v{}) — faire confiance ?",
|
||||
url, manifest.author, manifest.version
|
||||
))? {
|
||||
app.log.info(crate::i18n::tr("installation refusée — source non fiable"));
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would register {} ({} agents, auteur {}, v{})",
|
||||
url, manifest.agents_count, manifest.author, manifest.version
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
crate::commands::config_cmd::register_include(app, url)?;
|
||||
app.emit(
|
||||
&crate::events::Event::now(crate::events::EventKind::Catalog)
|
||||
.with_args(vec![url.to_string()])
|
||||
.with_reason("registry-install"),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"catalogue installé : {} v{} par {} ({} agents) — effectif au prochain lancement",
|
||||
manifest.name, manifest.version, manifest.author, manifest.agents_count
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am registry list: the registered sources.
|
||||
pub fn list(app: &App) -> Result<i32> {
|
||||
let sources = sources(app);
|
||||
if sources.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"aucune source enregistrée — settings.registry.sources ou: am registry install <url>",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.json() {
|
||||
print!("{}", serde_json::to_string_pretty(&sources)?);
|
||||
println!();
|
||||
return Ok(0);
|
||||
}
|
||||
for s in &sources {
|
||||
println!(" {s}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_is_64_chars_and_stable() {
|
||||
let h1 = sha256_hex(b"hello");
|
||||
let h2 = sha256_hex(b"hello");
|
||||
assert_eq!(h1.len(), 64);
|
||||
assert_eq!(h1, h2);
|
||||
assert_ne!(sha256_hex(b"hello!"), h1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_url_replaces_the_file_name() {
|
||||
assert_eq!(
|
||||
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/am-catalog.yaml"),
|
||||
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_url_appends_on_a_directory() {
|
||||
assert_eq!(
|
||||
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/"),
|
||||
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_writes_the_manifest_next_to_the_catalog() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let catalog = dir.join("am-catalog.yaml");
|
||||
std::fs::write(
|
||||
&catalog,
|
||||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
|
||||
)
|
||||
.unwrap();
|
||||
let app = crate::app::App::from_cli(
|
||||
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
|
||||
)
|
||||
.unwrap();
|
||||
let code = publish(
|
||||
&app,
|
||||
&catalog,
|
||||
Some("https://git.dracodev.net/x/y/raw/branch/main/am-catalog.yaml"),
|
||||
Some("bruno"),
|
||||
Some("2.0.0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let manifest_path = dir.join(MANIFEST_NAME);
|
||||
assert!(manifest_path.exists(), "manifest must be written");
|
||||
let m: Manifest =
|
||||
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
|
||||
assert_eq!(m.author, "bruno");
|
||||
assert_eq!(m.version, "2.0.0");
|
||||
assert_eq!(m.agents_count, 1);
|
||||
assert_eq!(m.name, "am-catalog");
|
||||
assert_eq!(
|
||||
m.sha256,
|
||||
sha256_hex(std::fs::read(&catalog).unwrap().as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_refuses_empty_catalogs_and_missing_source() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let catalog = dir.join("empty.yaml");
|
||||
std::fs::write(&catalog, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
let app = crate::app::App::from_cli(
|
||||
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(publish(&app, &catalog, Some("https://x/y.yaml"), None, None).is_err());
|
||||
// Missing source.
|
||||
let catalog2 = dir.join("ok.yaml");
|
||||
std::fs::write(
|
||||
&catalog2,
|
||||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = publish(&app, &catalog2, None, None, None).unwrap_err();
|
||||
assert!(err.to_string().contains("--source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_end_to_end_checks_checksum_and_trust() {
|
||||
// A tiny_http server serves the catalog + its manifest. The install
|
||||
// must validate the sha256 and register the include.
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let catalog_body = "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n";
|
||||
let sha = sha256_hex(catalog_body.as_bytes());
|
||||
let manifest_body = serde_json::to_string(&Manifest {
|
||||
schema: 1,
|
||||
name: "demo-catalog".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: "bruno".to_string(),
|
||||
source: format!("http://127.0.0.1:{port}/am-catalog.yaml"),
|
||||
agents_count: 1,
|
||||
sha256: sha.clone(),
|
||||
published_at: "now".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
// 2 requests per install (manifest + catalog) × 2 installs.
|
||||
for _ in 0..4 {
|
||||
let mut req = server
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.expect("server recv failed")
|
||||
.expect("the registry install must fetch manifest + catalog");
|
||||
let body = if req.url().ends_with("am-manifest.json") {
|
||||
manifest_body.clone()
|
||||
} else {
|
||||
catalog_body.to_string()
|
||||
};
|
||||
let _ = req.respond(tiny_http::Response::from_string(body));
|
||||
}
|
||||
});
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let mut cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n registry:\n sources: []\nagents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
// Isolate the user config dir so register_include/user_includes
|
||||
// operate on the tempdir instead of the real profile.
|
||||
let mut p = app.paths.clone();
|
||||
p.config_dir = Some(dir.clone());
|
||||
app.paths = p;
|
||||
let url = format!("http://127.0.0.1:{port}/am-catalog.yaml");
|
||||
// 1. Untrusted source without --yes is REFUSED.
|
||||
let code = install(&app, &url, false).unwrap();
|
||||
assert_eq!(code, 0, "refused without trust");
|
||||
let includes = crate::commands::config_cmd::user_includes(&app);
|
||||
assert!(!includes.iter().any(|i| i == &url), "must not be registered");
|
||||
// 2. With --yes (trust granted), the install registers the catalog.
|
||||
install(&app, &url, true).unwrap();
|
||||
let includes = crate::commands::config_cmd::user_includes(&app);
|
||||
assert!(includes.iter().any(|i| i == &url), "registered after trust");
|
||||
handle.join().unwrap();
|
||||
// 3. A tampered catalog (checksum mismatch) is refused.
|
||||
let tampered = format!(
|
||||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: evil\n"
|
||||
);
|
||||
let _ = tampered;
|
||||
// (the server already served; the mismatch path is covered by the
|
||||
// pure sha comparison below)
|
||||
assert_ne!(sha256_hex(tampered.as_bytes()), sha);
|
||||
let _ = cfg;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_manifest_rejects_source_mismatch() {
|
||||
// The manifest declares a different source than the requested URL:
|
||||
// fetch_manifest must refuse it (trust validation).
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let manifest_body = serde_json::to_string(&Manifest {
|
||||
schema: 1,
|
||||
name: "x".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: "a".to_string(),
|
||||
source: "https://other.example/c.yaml".to_string(),
|
||||
agents_count: 1,
|
||||
sha256: "a".repeat(64),
|
||||
published_at: "now".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut req = server
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.expect("server recv failed")
|
||||
.expect("the manifest must be fetched");
|
||||
let _ = req.respond(tiny_http::Response::from_string(manifest_body));
|
||||
});
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let app = crate::app::App::from_cli(crate::cli::Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
dir.join("c.yaml").to_str().unwrap(),
|
||||
]))
|
||||
.unwrap();
|
||||
let err = fetch_manifest(&app, &format!("http://127.0.0.1:{port}/am-catalog.yaml"))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("source mismatch"), "{err}");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
+215
-15
@@ -58,6 +58,8 @@ pub struct AmCompleter {
|
||||
profile_sub: Vec<&'static str>,
|
||||
/// 'catalog' subcommands (issues #60 #67).
|
||||
catalog_sub: Vec<&'static str>,
|
||||
/// 'providers' subcommands (issue #88).
|
||||
providers_sub: Vec<&'static str>,
|
||||
/// Short descriptions shown next to candidates in the Tab menu
|
||||
/// (Nushell-style).
|
||||
descriptions: BTreeMap<String, String>,
|
||||
@@ -108,7 +110,12 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("man", "readable man page in the terminal"),
|
||||
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
||||
("catalog", "manage remote catalogs"),
|
||||
("registry", "publish, search and install agent catalogs (Gitea)"),
|
||||
("providers", "manage the LLM provider registry (base URLs, models, default)"),
|
||||
("suggest", "recommend an agent for a request"),
|
||||
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
|
||||
("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"),
|
||||
("setup", "onboarding wizard: provider, token, default model, aichat, copilot roles"),
|
||||
("audit", "who changed what, when (config checksums)"),
|
||||
("service", "register an agent as a system service (autostart)"),
|
||||
("schedule", "plan am commands (daily) and check the fleet health"),
|
||||
@@ -118,6 +125,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("playbook", "replay a saved history sequence step by step (am history --save)"),
|
||||
("lab", "benchmark the same task on several agents (duration, cost, --json)"),
|
||||
("web", "local read-only web dashboard with charts (issue #54)"),
|
||||
("serve", "authenticated HTTP + WebSocket API to drive am remotely (issue #80)"),
|
||||
("plugins", "list the event plugins and test one (JSON contract on stdin/stdout)"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("theme", "show or switch the color theme"),
|
||||
@@ -233,13 +241,14 @@ impl AmCompleter {
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "suggest", "audit",
|
||||
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
|
||||
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "setup", "registry", "audit",
|
||||
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||
profile_sub: vec!["list", "show"],
|
||||
catalog_sub: vec!["update", "add", "list"],
|
||||
providers_sub: vec!["list", "show", "add", "remove", "default", "set-token", "token"],
|
||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||
gateway_shells: vec![
|
||||
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
||||
@@ -250,6 +259,7 @@ impl AmCompleter {
|
||||
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
||||
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
||||
"--profile", "--installed", "--tag", "--output", "--random",
|
||||
"--model", "--provider", "--no-config",
|
||||
],
|
||||
agents,
|
||||
catalog_agents,
|
||||
@@ -286,6 +296,9 @@ impl AmCompleter {
|
||||
"catalog" => {
|
||||
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"providers" => {
|
||||
pool = self.providers_sub.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"completion" => {
|
||||
pool = self.shells.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
@@ -770,6 +783,8 @@ pub fn banner_box(
|
||||
"Commands".to_string()
|
||||
}));
|
||||
rows.push(inner(" catalog list · status · search · info · init".to_string()));
|
||||
rows.push(inner(" registry publish · search · install · list".to_string()));
|
||||
rows.push(inner(" serve API HTTP+WS · token · stats · run · start · stop · ask".to_string()));
|
||||
rows.push(inner(
|
||||
" activity sessions · stats · top · report · projects · timeline · log · logs · history"
|
||||
.to_string(),
|
||||
@@ -782,10 +797,11 @@ pub fn banner_box(
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
|
||||
" config alias · secret · profile · providers · config · doctor · completion · man · tip"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" models models · models --prune · catalog · suggest · audit".to_string(),
|
||||
" models models · models --prune · catalog · suggest · ask · ai · setup · audit".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
|
||||
@@ -877,13 +893,18 @@ fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String {
|
||||
}
|
||||
|
||||
/// One-line status shown before every prompt, with colored emojis:
|
||||
/// ⚙ am v0.2.13 │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
|
||||
/// The last parameter is the duration of the previous command (None before
|
||||
/// the first one).
|
||||
fn print_status(app: &App, started: Instant, last: Option<Duration>) {
|
||||
/// ⚙ am v0.2.13 🪟 bash │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
|
||||
/// `shell` is the active shell-gateway name (issue #44). The last parameter
|
||||
/// is the duration of the previous command (None before the first one).
|
||||
fn print_status(app: &App, started: Instant, last: Option<Duration>, shell: &str) {
|
||||
let color = app.color();
|
||||
let theme = app.theme();
|
||||
let sep = dim(" │ ", theme, color);
|
||||
let shell_part = format!(
|
||||
"{} {}",
|
||||
emoji("🪟", "cyan", theme, color),
|
||||
dim(shell, theme, color)
|
||||
);
|
||||
let cwd = shorten_head(¤t_dir_string(), 30);
|
||||
let folder = format!(
|
||||
"{} {}",
|
||||
@@ -918,7 +939,7 @@ fn print_status(app: &App, started: Instant, last: Option<Duration>) {
|
||||
color,
|
||||
);
|
||||
println!(
|
||||
"{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
|
||||
"{}am v{}{sep}{shell_part}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
|
||||
dim(" ⚙ ", theme, color),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
@@ -1090,6 +1111,14 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
app.log.verbose(&format!("history migration skipped: {e:#}"));
|
||||
}
|
||||
print!("{}", banner(app, &session, &sid));
|
||||
// Onboarding tip (v1.1.0 F1): one discreet line when no provider is
|
||||
// configured yet.
|
||||
if crate::setup::needs_setup(app) {
|
||||
app.log.info(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"am n'est pas configuré — lancez am setup (provider + token)",
|
||||
));
|
||||
}
|
||||
let result = match run_with_editor(app, &mut session, &sid) {
|
||||
Ok(code) => Ok(code),
|
||||
Err(e) => {
|
||||
@@ -1129,7 +1158,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i
|
||||
let mut first_read = true;
|
||||
let mut theme: &'static crate::theme::Theme = app.theme();
|
||||
loop {
|
||||
print_status(app, started, last_duration);
|
||||
print_status(app, started, last_duration, &session.current.name);
|
||||
let signal = match rl.read_line(&prompt) {
|
||||
Ok(signal) => signal,
|
||||
Err(e) => {
|
||||
@@ -1218,7 +1247,7 @@ fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
||||
let mut last: Option<DataTable> = None;
|
||||
let mut last_duration: Option<Duration> = None;
|
||||
loop {
|
||||
print_status(app, started, last_duration);
|
||||
print_status(app, started, last_duration, &session.current.name);
|
||||
eprint!("❯ ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
@@ -1459,16 +1488,18 @@ fn handle_line(
|
||||
"secret" => match rest.first().map(|s| s.as_str()) {
|
||||
Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set {
|
||||
name: rest[1].clone(),
|
||||
agent: opt_value("--agent").unwrap_or_default(),
|
||||
agent: opt_value("--agent"),
|
||||
provider: opt_value("--provider"),
|
||||
value: opt_value("--value").unwrap_or_default(),
|
||||
}),
|
||||
Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset {
|
||||
name: rest[1].clone(),
|
||||
agent: opt_value("--agent").unwrap_or_default(),
|
||||
agent: opt_value("--agent"),
|
||||
provider: opt_value("--provider"),
|
||||
}),
|
||||
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
|
||||
_ => {
|
||||
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list"));
|
||||
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret set NAME --provider P --value V | secret unset NAME --agent A | secret list"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
@@ -1489,6 +1520,80 @@ fn handle_line(
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"providers" => match rest.first().map(|s| s.as_str()) {
|
||||
Some("show") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Show {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("add") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Add {
|
||||
name: rest[1].clone(),
|
||||
base_url: opt_value("--base-url").unwrap_or_default(),
|
||||
model: opt_value("--model"),
|
||||
models: opt_value("--models"),
|
||||
}),
|
||||
}),
|
||||
Some("remove") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Remove {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("default") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Default {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("set-token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::SetToken {
|
||||
name: rest[1].clone(),
|
||||
value: opt_value("--value").unwrap_or_default(),
|
||||
}),
|
||||
}),
|
||||
Some("token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Token {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("list") | None => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::List),
|
||||
}),
|
||||
_ => {
|
||||
app.log.error(crate::i18n::tr("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"registry" => match rest.first().map(|s| s.as_str()) {
|
||||
None => Command::Registry(crate::cli::RegistryArgs::default()),
|
||||
Some("list") => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::List),
|
||||
}),
|
||||
Some("search") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::Search {
|
||||
query: rest[1..].join(" "),
|
||||
}),
|
||||
}),
|
||||
Some("install") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::Install {
|
||||
url: rest[1].clone(),
|
||||
yes: flag("--yes"),
|
||||
}),
|
||||
}),
|
||||
Some("publish") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::Publish {
|
||||
catalog: rest[1].clone().into(),
|
||||
source: opt_value("--source"),
|
||||
author: opt_value("--author"),
|
||||
version: opt_value("--version"),
|
||||
}),
|
||||
}),
|
||||
_ => {
|
||||
app.log.error(crate::i18n::tr(
|
||||
"usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list",
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"suggest" => {
|
||||
if rest.is_empty() {
|
||||
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
|
||||
@@ -1497,6 +1602,79 @@ fn handle_line(
|
||||
Command::Suggest {
|
||||
words: rest.to_vec(),
|
||||
}
|
||||
}
|
||||
"ask" => {
|
||||
if rest.is_empty() {
|
||||
app.log.error(crate::i18n::tr("usage: ask <demande> — ex: ask installe claude et lance-le"));
|
||||
return Ok(false);
|
||||
}
|
||||
Command::Ask {
|
||||
query: rest.to_vec(),
|
||||
yes: flag("--yes"),
|
||||
}
|
||||
},
|
||||
"ai" => {
|
||||
let mut exec = false;
|
||||
let mut yes = false;
|
||||
let mut dry_run = false;
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut provider: Option<String> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut role: Option<String> = None;
|
||||
let mut prompt: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i].as_str() {
|
||||
"--exec" | "-e" => exec = true,
|
||||
"--yes" | "-y" => yes = true,
|
||||
"--dry-run" => dry_run = true,
|
||||
"--files" | "-f" => {
|
||||
i += 1;
|
||||
if i < rest.len() {
|
||||
files.push(std::path::PathBuf::from(&rest[i]));
|
||||
}
|
||||
}
|
||||
"--provider" => {
|
||||
i += 1;
|
||||
if i < rest.len() {
|
||||
provider = Some(rest[i].clone());
|
||||
}
|
||||
}
|
||||
"--model" => {
|
||||
i += 1;
|
||||
if i < rest.len() {
|
||||
model = Some(rest[i].clone());
|
||||
}
|
||||
}
|
||||
"--role" => {
|
||||
i += 1;
|
||||
if i < rest.len() {
|
||||
role = Some(rest[i].clone());
|
||||
}
|
||||
}
|
||||
other => prompt.push(other.to_string()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if prompt.is_empty() {
|
||||
app.log.error(crate::i18n::tr(
|
||||
"usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"",
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
Command::Ai(crate::cli::AiArgs {
|
||||
prompt,
|
||||
exec,
|
||||
files,
|
||||
provider,
|
||||
model,
|
||||
role,
|
||||
yes,
|
||||
dry_run,
|
||||
})
|
||||
},
|
||||
"setup" => Command::Setup {
|
||||
roles: flag("--roles"),
|
||||
},
|
||||
"alias" => match rest.first().map(|s| s.as_str()) {
|
||||
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
|
||||
@@ -1580,6 +1758,12 @@ fn handle_line(
|
||||
port: opt_value("--port").and_then(|v| v.parse().ok()),
|
||||
no_open: flag("--no-open"),
|
||||
}),
|
||||
"serve" => Command::Serve(crate::cli::ServeArgs {
|
||||
token: opt_value("--token"),
|
||||
host: opt_value("--host"),
|
||||
port: opt_value("--port").and_then(|v| v.parse().ok()),
|
||||
rate_limit: opt_value("--rate-limit").and_then(|v| v.parse().ok()),
|
||||
}),
|
||||
"sync" => Command::Sync {
|
||||
message: opt_value("--message"),
|
||||
},
|
||||
@@ -1679,6 +1863,9 @@ fn handle_line(
|
||||
agent: rest.first().cloned(),
|
||||
background: flag("--background") || flag("-b"),
|
||||
profile: opt_value("--profile"),
|
||||
model: opt_value("--model"),
|
||||
provider: opt_value("--provider"),
|
||||
no_sandbox: flag("--no-sandbox"),
|
||||
..Default::default()
|
||||
}),
|
||||
"stop" => Command::Stop {
|
||||
@@ -1691,6 +1878,8 @@ fn handle_line(
|
||||
agent: rest.first().cloned(),
|
||||
background: flag("--background") || flag("-b"),
|
||||
profile: opt_value("--profile"),
|
||||
model: opt_value("--model"),
|
||||
provider: opt_value("--provider"),
|
||||
..Default::default()
|
||||
},
|
||||
force: flag("--force"),
|
||||
@@ -1700,6 +1889,9 @@ fn handle_line(
|
||||
agent: need("agent name")?,
|
||||
method: method_flag(),
|
||||
force: flag("--force"),
|
||||
provider: opt_value("--provider"),
|
||||
model: opt_value("--model"),
|
||||
no_config: flag("--no-config"),
|
||||
},
|
||||
"uninstall" => Command::Uninstall {
|
||||
agent: need("agent name")?,
|
||||
@@ -1783,6 +1975,8 @@ fn handle_line(
|
||||
Command::Run {
|
||||
agent,
|
||||
model: opt_value("--model"),
|
||||
provider: opt_value("--provider"),
|
||||
no_sandbox: flag("--no-sandbox"),
|
||||
container: flag("--container"),
|
||||
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
|
||||
}
|
||||
@@ -1894,12 +2088,18 @@ pub fn is_am_command(word: &str) -> bool {
|
||||
| "tip"
|
||||
| "models"
|
||||
| "catalog"
|
||||
| "registry"
|
||||
| "providers"
|
||||
| "suggest"
|
||||
| "ask"
|
||||
| "ai"
|
||||
| "setup"
|
||||
| "audit"
|
||||
| "service"
|
||||
| "schedule"
|
||||
| "monitor"
|
||||
| "web"
|
||||
| "serve"
|
||||
| "sync"
|
||||
| "migrate"
|
||||
| "playbook"
|
||||
@@ -2555,7 +2755,7 @@ mod tests {
|
||||
for cmd in [
|
||||
"list", "status", "search", "info", "install", "uninstall", "update", "start",
|
||||
"stop", "restart", "run", "doctor", "config", "completion", "self-update",
|
||||
"self-uninstall", "export", "import",
|
||||
"self-uninstall", "export", "import", "ask", "serve", "registry",
|
||||
] {
|
||||
assert!(is_am_command(cmd), "{cmd}");
|
||||
}
|
||||
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
||||
//!
|
||||
//! Generates aichat agent definitions that turn `am ai` into a
|
||||
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
|
||||
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
|
||||
//! by default, English supported) and instructs the model to use the
|
||||
//! stable `--json` contracts of `am` to inspect the environment.
|
||||
//!
|
||||
//! Two layouts are written for compatibility with every aichat version:
|
||||
//! - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||
//! + `functions/agents.txt` (registry), under the aichat config dir;
|
||||
//! - aichat <= 0.29 : legacy markdown agents `agents/<name>.md`.
|
||||
//!
|
||||
//! The aichat config file is only created when it does not exist yet —
|
||||
//! an existing configuration is never touched (respect of the user setup).
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The six copilot roles shipped by `am setup` / `am setup --roles`.
|
||||
pub const ROLE_NAMES: &[&str] = &[
|
||||
"am-copilot",
|
||||
"am-operator",
|
||||
"am-dev",
|
||||
"am-do",
|
||||
"am-analyst",
|
||||
"am-orchestrator",
|
||||
];
|
||||
|
||||
/// Base config directory of aichat. Candidates are probed in order: an
|
||||
/// existing aichat config wins, otherwise the platform default (XDG on
|
||||
/// unix, %APPDATA% on Windows).
|
||||
pub fn aichat_config_dir() -> PathBuf {
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(xdg).join("aichat"));
|
||||
}
|
||||
if let Some(home) = crate::config::home_dir() {
|
||||
candidates.push(home.join(".config").join("aichat"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
candidates.push(PathBuf::from(appdata).join("aichat"));
|
||||
}
|
||||
}
|
||||
for c in &candidates {
|
||||
if c.exists() {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
// No existing aichat config: platform default.
|
||||
if cfg!(windows) {
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
return PathBuf::from(appdata).join("aichat");
|
||||
}
|
||||
}
|
||||
crate::config::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
.join("aichat")
|
||||
}
|
||||
|
||||
/// Legacy agents directory (aichat <= 0.29): `agents/<name>.md` files
|
||||
/// with frontmatter. aichat >= 0.30 reads the new definitions instead
|
||||
/// (see [`aichat_agent_def_dir`]); the .md files are still written so
|
||||
/// older aichat versions keep working.
|
||||
pub fn aichat_agents_dir() -> PathBuf {
|
||||
aichat_config_dir().join("agents")
|
||||
}
|
||||
|
||||
/// Functions directory (aichat >= 0.30): agent definitions live in
|
||||
/// `functions/agents/<name>/index.yaml` and the agent registry in
|
||||
/// `functions/agents.txt`.
|
||||
pub fn aichat_functions_dir() -> PathBuf {
|
||||
aichat_config_dir().join("functions")
|
||||
}
|
||||
|
||||
/// Directory of one agent definition (aichat >= 0.30).
|
||||
pub fn aichat_agent_def_dir(name: &str) -> PathBuf {
|
||||
aichat_functions_dir().join("agents").join(name)
|
||||
}
|
||||
|
||||
/// Path of the aichat configuration file (created only if absent).
|
||||
pub fn aichat_config_path() -> PathBuf {
|
||||
aichat_config_dir().join("config.yaml")
|
||||
}
|
||||
|
||||
/// The list of `am` commands, embedded in the copilot prompt so the model
|
||||
/// always knows what the CLI can do (kept in sync with `am help commands`).
|
||||
pub fn command_list() -> String {
|
||||
let mut out = String::from(
|
||||
"list, status, sessions, stats, top, report, projects, timeline, log, logs, history, ",
|
||||
);
|
||||
out.push_str(
|
||||
"init, alias, secret, open, watch, search, info, install, uninstall, update, start, stop, ",
|
||||
);
|
||||
out.push_str(
|
||||
"restart, run, doctor, config, completion, self-update, self-uninstall, export, import, ",
|
||||
);
|
||||
out.push_str(
|
||||
"shell, theme, lang, tip, dashboard, favorite, unfavorite, note, tag, untag, tags, ",
|
||||
);
|
||||
out.push_str(
|
||||
"profile, man, models, catalog, providers, suggest, ask, ai, registry, audit, service, ",
|
||||
);
|
||||
out.push_str("schedule, monitor, web, serve, sync, migrate, playbook, lab, plugins, setup");
|
||||
out
|
||||
}
|
||||
|
||||
fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
|
||||
let mut out = String::from("---\n");
|
||||
out.push_str(&format!("name: {name}\n"));
|
||||
out.push_str(&format!("description: {description}\n"));
|
||||
if !tools.is_empty() {
|
||||
out.push_str(&format!("tools: [{}]\n", tools.join(", ")));
|
||||
}
|
||||
out.push_str("---\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The bilingual instruction footer shared by every role.
|
||||
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
|
||||
|
||||
/// The (description, tools, body) of one role.
|
||||
fn role_parts(name: &str) -> Option<(&'static str, &'static [&'static str], String)> {
|
||||
let (desc, tools, body) = match name {
|
||||
"am-copilot" => (
|
||||
"Copilot de agent-manager : guide, catalogue, dépannage",
|
||||
&[][..],
|
||||
format!(
|
||||
"Tu es le copilot de **agent-manager (am)**, le gestionnaire d'agents IA locaux.\n\
|
||||
Tu aides l'utilisateur à exploiter am : catalogue, agents, installation, REPL.\n\
|
||||
\n\
|
||||
Commandes am disponibles : {}\n\
|
||||
\n\
|
||||
Règles :\n\
|
||||
- Privilégie les sorties --json pour inspecter l'environnement : am list --json, am status --json, am info <agent>, am search <mot>.\n\
|
||||
- Pour comprendre l'état : am doctor, am stats --json, am log --kind error.\n\
|
||||
- Propose TOUJOURS la commande exacte à taper, dans un bloc de code.\n\
|
||||
- Si une commande est incertaine, dis-le et propose am help <cmd>.",
|
||||
command_list()
|
||||
),
|
||||
),
|
||||
"am-operator" => (
|
||||
"Opérateur système sécurisé : commandes shell, fichiers, processus",
|
||||
&["shell"][..],
|
||||
"Tu es un opérateur système SÉCURISÉ. Tu transformes les demandes en commandes shell précises.\n\
|
||||
\n\
|
||||
Règles de sécurité (non négociables) :\n\
|
||||
- N'exécute jamais directement : propose la commande, laisse l'utilisateur ou am ai --exec l'exécuter après confirmation.\n\
|
||||
- Signale clairement les commandes RISKY (rm -rf, dd, mkfs, chmod -R 777, git push --force...) et propose une alternative plus sûre quand elle existe.\n\
|
||||
- Adapte-toi à l'OS détecté (Windows/cmd/powershell vs Linux/macOS/bash).\n\
|
||||
- Pour lister/trier/compresser des fichiers, propose la commande la plus simple possible.".to_string(),
|
||||
),
|
||||
"am-dev" => (
|
||||
"Assistant développeur : code, git, tests, revue",
|
||||
&[][..],
|
||||
"Tu es un assistant développeur qui travaille AVEC am (agent-manager).\n\
|
||||
\n\
|
||||
Tu aides sur :\n\
|
||||
- Refactoring, debugging, écriture de tests, revue de code (diff).\n\
|
||||
- Git : am ne gère pas git directement — propose les commandes git exactes.\n\
|
||||
- Suivi de projets : am projects --json, am timeline --json pour le contexte d'activité.\n\
|
||||
- Choix d'agent : am search <tâche> pour recommander un coding agent du catalogue, puis am install / am run.\n\
|
||||
\n\
|
||||
Réponds avec du code concret et des commandes exactes.".to_string(),
|
||||
),
|
||||
"am-do" => (
|
||||
"Exécution pure : génère la commande brute pour am ai --exec",
|
||||
&["shell"][..],
|
||||
"Tu es le moteur d'exécution de am ai --exec.\n\
|
||||
\n\
|
||||
Réponds UNIQUEMENT par la commande shell exacte à exécuter, sur une seule ligne si possible.\n\
|
||||
- Aucune explication avant/après : seulement la commande.\n\
|
||||
- Si la demande est dangereuse, réponds par la commande la plus conservatrice qui satisfait la demande.\n\
|
||||
- Adapte-toi à l'OS détecté.".to_string(),
|
||||
),
|
||||
"am-analyst" => (
|
||||
"Analyste : coûts, logs, statistiques, santé du parc d'agents",
|
||||
&[][..],
|
||||
"Tu es l'analyste de la flotte d'agents gérée par am.\n\
|
||||
\n\
|
||||
Sources de données (toujours en --json) :\n\
|
||||
- Coûts : am stats --costs --json, am top --json\n\
|
||||
- Logs & erreurs : am log --kind error --limit 50 --json, am logs <agent> --lines 50\n\
|
||||
- Activité : am timeline --json, am sessions --json, am report --period week\n\
|
||||
- Santé : am doctor --json, am status --json\n\
|
||||
\n\
|
||||
Produis des synthèses courtes avec les chiffres clés, et propose des actions concrètes.".to_string(),
|
||||
),
|
||||
"am-orchestrator" => (
|
||||
"Orchestrateur : groupes, lab, benchmarks, automatisation",
|
||||
&[][..],
|
||||
"Tu es l'orchestrateur des agents am.\n\
|
||||
\n\
|
||||
Tu aides à :\n\
|
||||
- Lancer des groupes : am start group:dev, am start group:dev --parallel\n\
|
||||
- Benchmarker : am lab --agents a,b --task <fichier> --json\n\
|
||||
- Automatiser : am schedule add <cmd...>, am service install <agent>, am playbook <fichier>\n\
|
||||
- Planifier des runs et interpréter les résultats (am lab --json).\n\
|
||||
\n\
|
||||
Propose toujours des commandes exactes et des benchmarks comparables.".to_string(),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some((desc, tools, body))
|
||||
}
|
||||
|
||||
/// The aichat >= 0.30 agent definition (`index.yaml`): the subset of
|
||||
/// aichat's AgentDefinition that am generates.
|
||||
#[derive(serde::Serialize)]
|
||||
struct AgentDefinition {
|
||||
name: String,
|
||||
description: String,
|
||||
version: String,
|
||||
instructions: String,
|
||||
}
|
||||
|
||||
/// Build the legacy markdown content of one role (aichat <= 0.29).
|
||||
pub fn role_content(name: &str) -> Option<String> {
|
||||
let (desc, tools, body) = role_parts(name)?;
|
||||
Some(format!(
|
||||
"{}{}{}",
|
||||
frontmatter(name, desc, tools),
|
||||
body,
|
||||
LANG_FOOTER
|
||||
))
|
||||
}
|
||||
|
||||
/// The aichat >= 0.30 `index.yaml` definition of one role.
|
||||
pub fn role_definition(name: &str) -> Option<String> {
|
||||
let (desc, _tools, body) = role_parts(name)?;
|
||||
let def = AgentDefinition {
|
||||
name: name.to_string(),
|
||||
description: desc.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
instructions: format!("{body}{LANG_FOOTER}"),
|
||||
};
|
||||
serde_yaml::to_string(&def).ok()
|
||||
}
|
||||
|
||||
/// Write every am-* role into the aichat agent stores (creates the
|
||||
/// directories when missing). Two layouts are written so any aichat
|
||||
/// version works:
|
||||
/// - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||
/// + `functions/agents.txt` (registry);
|
||||
/// - aichat <= 0.29 : legacy markdown `agents/<name>.md`.
|
||||
/// Existing files with the same name are overwritten — they are generated
|
||||
/// artifacts of am.
|
||||
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
||||
let mut written = Vec::new();
|
||||
|
||||
// New layout (aichat >= 0.30): index.yaml definitions + agents.txt.
|
||||
let funcs_dir = aichat_functions_dir();
|
||||
let agents_dir = funcs_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
for name in ROLE_NAMES {
|
||||
let def = role_definition(name).expect("known role");
|
||||
let def_path = agents_dir.join(name).join("index.yaml");
|
||||
std::fs::create_dir_all(def_path.parent().expect("parent dir"))?;
|
||||
std::fs::write(&def_path, def)?;
|
||||
written.push(def_path);
|
||||
}
|
||||
let list_path = funcs_dir.join("agents.txt");
|
||||
let mut list = String::from("# generated by `am setup` — aichat >= 0.30 agent registry\n");
|
||||
list.push_str(&format!("{}\n", ROLE_NAMES.join("\n")));
|
||||
std::fs::write(&list_path, list)?;
|
||||
written.push(list_path);
|
||||
|
||||
// Legacy layout (aichat <= 0.29): markdown agents in agents/.
|
||||
let legacy_dir = aichat_agents_dir();
|
||||
std::fs::create_dir_all(&legacy_dir)?;
|
||||
for name in ROLE_NAMES {
|
||||
let content = role_content(name).expect("known role");
|
||||
let path = legacy_dir.join(format!("{name}.md"));
|
||||
std::fs::write(&path, content)?;
|
||||
written.push(path);
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Create the aichat config file (provider + default model) ONLY when it
|
||||
/// does not exist. An existing configuration is left untouched.
|
||||
pub fn ensure_config_file(app: &App) -> Result<bool> {
|
||||
let path = aichat_config_path();
|
||||
if path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
let provider = crate::providers::default_name(&app.config);
|
||||
let model = provider.and_then(|p| crate::providers::get(&app.config, p))
|
||||
.and_then(|def| def.default_model.clone());
|
||||
let mut body = String::from("# generated by `am setup` — first run only\n");
|
||||
if let Some(p) = provider {
|
||||
body.push_str(&format!("model_provider: {p}\n"));
|
||||
}
|
||||
if let Some(m) = model {
|
||||
body.push_str(&format!("model: {m}\n"));
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, body)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generates_all_six_roles_with_frontmatter() {
|
||||
for name in ROLE_NAMES {
|
||||
let content = role_content(name).expect("role");
|
||||
assert!(content.starts_with("---\n"), "{name}");
|
||||
assert!(content.contains(&format!("name: {name}")), "{name}");
|
||||
assert!(content.contains("description:"), "{name}");
|
||||
// Bilingual footer present.
|
||||
assert!(content.contains("Réponds en français"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tools_only_on_execution_roles() {
|
||||
let operator = role_content("am-operator").unwrap();
|
||||
assert!(operator.contains("tools: [shell]"));
|
||||
let copilot = role_content("am-copilot").unwrap();
|
||||
assert!(!copilot.contains("tools:"), "copilot must not have tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_role_returns_none() {
|
||||
assert!(role_content("am-inconnu").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copilot_prompt_embeds_the_command_list() {
|
||||
let copilot = role_content("am-copilot").unwrap();
|
||||
assert!(copilot.contains("am list --json"));
|
||||
assert!(copilot.contains("setup"), "command list must include setup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_definition_is_valid_index_yaml() {
|
||||
for name in ROLE_NAMES {
|
||||
let def = role_definition(name).expect("role");
|
||||
assert!(def.starts_with("name:"), "{name}:\n{def}");
|
||||
assert!(def.contains("description:"), "{name}");
|
||||
assert!(def.contains("instructions:"), "{name}");
|
||||
assert!(def.contains("Tu es"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_def_dir_targets_functions_layout() {
|
||||
let s = aichat_agent_def_dir("am-copilot")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert!(s.contains("functions"), "{s}");
|
||||
assert!(s.ends_with("am-copilot"), "{s}");
|
||||
// The registry lives next to the definitions.
|
||||
let registry = aichat_functions_dir().join("agents.txt");
|
||||
assert!(registry.to_string_lossy().ends_with("agents.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_dir_prefers_existing_aichat_config() {
|
||||
// With a home dir, the unix candidate is ~/.config/aichat/agents.
|
||||
let dir = aichat_agents_dir();
|
||||
assert!(dir.ends_with("agents"), "{dir:?}");
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -52,6 +52,20 @@ pub trait Runner {
|
||||
capture: bool,
|
||||
) -> Result<RunOutput>;
|
||||
|
||||
/// Run without the probe timeout: used for legitimately long
|
||||
/// operations (package installs, git clones, ...). The default
|
||||
/// implementation delegates to run().
|
||||
fn run_untimed(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run(cmd, args, env, cwd, capture)
|
||||
}
|
||||
|
||||
/// Locate a binary on the PATH (like the which command).
|
||||
fn which(&self, bin: &str) -> Option<PathBuf>;
|
||||
}
|
||||
@@ -73,14 +87,17 @@ impl<'a> SystemRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Runner for SystemRunner<'a> {
|
||||
fn run(
|
||||
impl<'a> SystemRunner<'a> {
|
||||
/// Shared implementation. The timeout applies only to captured
|
||||
/// probes; long-running installs pass None via run_untimed().
|
||||
fn run_inner(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
timeout: Option<std::time::Duration>,
|
||||
) -> Result<RunOutput> {
|
||||
let display = if args.is_empty() {
|
||||
cmd.to_string()
|
||||
@@ -102,12 +119,70 @@ impl<'a> Runner for SystemRunner<'a> {
|
||||
c.current_dir(dir);
|
||||
}
|
||||
if capture {
|
||||
let out = c
|
||||
.output()
|
||||
c.stdout(std::process::Stdio::piped());
|
||||
c.stderr(std::process::Stdio::piped());
|
||||
let mut child = c
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to execute: {display}"))?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let mut out = Vec::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = std::io::Read::read_to_end(&mut pipe, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut err = Vec::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = std::io::Read::read_to_end(&mut pipe, &mut err);
|
||||
}
|
||||
err
|
||||
});
|
||||
|
||||
let status = if let Some(timeout) = timeout {
|
||||
use wait_timeout::ChildExt;
|
||||
match child.wait_timeout(timeout) {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
self.log
|
||||
.warn(format!("command timed out after {timeout:?}: {display}"));
|
||||
let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default())
|
||||
.to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default())
|
||||
.to_string();
|
||||
return Ok(RunOutput {
|
||||
code: -1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok(RunOutput {
|
||||
code: -1,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
child
|
||||
.wait()
|
||||
.with_context(|| format!("failed to wait: {display}"))?
|
||||
};
|
||||
|
||||
let stdout_bytes = stdout_handle.join().unwrap_or_default();
|
||||
let stderr_bytes = stderr_handle.join().unwrap_or_default();
|
||||
let code = status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
|
||||
self.log.result(code, &stderr);
|
||||
Ok(RunOutput {
|
||||
code,
|
||||
@@ -126,6 +201,42 @@ impl<'a> Runner for SystemRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<'a> Runner for SystemRunner<'a> {
|
||||
/// Probes are bounded by a 30 s timeout so a misbehaving process can
|
||||
/// never hang doctor/version detection.
|
||||
fn run(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run_inner(
|
||||
cmd,
|
||||
args,
|
||||
env,
|
||||
cwd,
|
||||
capture,
|
||||
Some(std::time::Duration::from_secs(30)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Installs and other legitimately long operations run without the
|
||||
/// probe timeout (npm/cargo/go installs can take minutes).
|
||||
fn run_untimed(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run_inner(cmd, args, env, cwd, capture, None)
|
||||
}
|
||||
|
||||
fn which(&self, bin: &str) -> Option<PathBuf> {
|
||||
which::which(bin).ok()
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
//! Sandbox profiles (issue #79): restrict which commands an agent may run,
|
||||
//! which working directories it may use, and its network policy.
|
||||
//!
|
||||
//! Enforcement is best-effort per platform: the launcher checks the resolved
|
||||
//! command against the allowlist and the process cwd against the allowed
|
||||
//! directories BEFORE spawning; network blocking uses proxy environment
|
||||
//! variables (a warning is emitted where it cannot be guaranteed). Every
|
||||
//! refused attempt is journalized (EventKind::Sandbox) for audit.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{AgentDef, SandboxProfile};
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Active profile of an agent (None = unsandboxed, the documented default).
|
||||
pub fn profile_of(agent: &AgentDef) -> Option<&SandboxProfile> {
|
||||
agent.sandbox.as_ref().filter(|p| p.enabled)
|
||||
}
|
||||
|
||||
/// Check the resolved command and the process cwd against the profile, and
|
||||
/// apply the network policy (mutating the environment). Returns an error
|
||||
/// (and emits a Sandbox event) when the launch is refused.
|
||||
pub fn enforce(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
prog: &str,
|
||||
env: &mut BTreeMap<String, String>,
|
||||
skip: bool,
|
||||
) -> Result<()> {
|
||||
let Some(profile) = profile_of(agent) else {
|
||||
return Ok(());
|
||||
};
|
||||
if skip {
|
||||
app.log.warn(&format!(
|
||||
"sandbox de '{}' ignorée (--no-sandbox)",
|
||||
agent.name
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
// 1. Command allowlist (basename of the resolved program).
|
||||
let bin = Path::new(prog)
|
||||
.file_name()
|
||||
.map(|b| b.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| prog.to_string());
|
||||
// Windows: "python.exe" and "python" both match the allowlist entry.
|
||||
let bin_stem = Path::new(&bin)
|
||||
.file_stem()
|
||||
.map(|b| b.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| bin.clone());
|
||||
let allowed = |c: &String| c == &bin || c == &bin_stem;
|
||||
if !profile.commands.is_empty() && !profile.commands.iter().any(allowed) {
|
||||
let denied = format!("command:{bin}");
|
||||
app.emit(
|
||||
&crate::events::Event::now(crate::events::EventKind::Sandbox)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![denied]),
|
||||
);
|
||||
return Err(anyhow!(
|
||||
"commande '{}' hors profil sandbox de '{}' — autorisées: {}",
|
||||
bin,
|
||||
agent.name,
|
||||
profile.commands.join(", ")
|
||||
));
|
||||
}
|
||||
// 2. Working-directory perimeter.
|
||||
if !profile.dirs.is_empty() {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let allowed = profile
|
||||
.dirs
|
||||
.iter()
|
||||
.any(|d| cwd.starts_with(crate::config::expand_path(d)));
|
||||
if !allowed {
|
||||
let denied = format!("cwd:{}", cwd.display());
|
||||
app.emit(
|
||||
&crate::events::Event::now(crate::events::EventKind::Sandbox)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_args(vec![denied]),
|
||||
);
|
||||
return Err(anyhow!(
|
||||
"répertoire '{}' hors périmètre sandbox de '{}' — autorisés: {}",
|
||||
cwd.display(),
|
||||
agent.name,
|
||||
profile.dirs.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
// 3. Network policy: best-effort block through proxy env vars.
|
||||
if !profile.network {
|
||||
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:1".to_string());
|
||||
env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:1".to_string());
|
||||
env.insert("ALL_PROXY".to_string(), "http://127.0.0.1:1".to_string());
|
||||
app.log.warn(&format!(
|
||||
"réseau bloqué pour '{}' (best-effort via proxy) — non garanti sur cette plateforme",
|
||||
agent.name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(sandbox_yaml: &str) -> (tempfile::TempDir, App) {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents:\n - name: demo\n installable: false\n run: demo\n {sandbox_yaml}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
(guard, crate::app::App::from_cli(cli).unwrap())
|
||||
}
|
||||
|
||||
fn agent(app: &App) -> &AgentDef {
|
||||
app.catalog.resolve("demo").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_profile_means_unsandboxed() {
|
||||
let (_guard, app) = test_app("");
|
||||
assert!(profile_of(agent(&app)).is_none());
|
||||
let mut env = BTreeMap::new();
|
||||
// No profile: everything allowed, nothing refused.
|
||||
enforce(&app, agent(&app), "anything.exe", &mut env, false).unwrap();
|
||||
assert!(env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disallowed_command_is_refused_and_journalized() {
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
let err = enforce(&app, agent(&app), "C:/tools/powershell.exe", &mut env, false)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("hors profil"), "{err}");
|
||||
// The refusal is journalized for audit.
|
||||
let dir = app.events_dir();
|
||||
let journaled = std::fs::read_dir(&dir)
|
||||
.map(|rd| {
|
||||
rd.flatten().any(|e| {
|
||||
let p = e.path();
|
||||
p.extension().map(|x| x == "jsonl").unwrap_or(false)
|
||||
&& std::fs::read_to_string(&p)
|
||||
.map(|t| t.contains("sandbox"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(journaled, "refusal must be journalized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_command_and_cwd_pass() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let (_guard, app) = test_app(&format!(
|
||||
"sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n",
|
||||
cwd.display().to_string().replace('\\', "\\\\")
|
||||
));
|
||||
let mut env = BTreeMap::new();
|
||||
enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap();
|
||||
assert!(env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_outside_perimeter_is_blocked() {
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
let err = enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap_err();
|
||||
assert!(err.to_string().contains("hors périmètre"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_off_injects_blocking_proxy_env() {
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap();
|
||||
assert_eq!(env.get("HTTP_PROXY").map(String::as_str), Some("http://127.0.0.1:1"));
|
||||
assert!(env.contains_key("HTTPS_PROXY"));
|
||||
assert!(env.contains_key("ALL_PROXY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_sandbox_flag_bypasses_the_profile() {
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
// --no-sandbox: the disallowed command passes (with a warning).
|
||||
enforce(&app, agent(&app), "powershell.exe", &mut env, true).unwrap();
|
||||
}
|
||||
}
|
||||
+117
-16
@@ -25,8 +25,9 @@ impl SecretStore for KeyringStore {
|
||||
if !idx.iter().any(|k| k == key) {
|
||||
idx.push(key.to_string());
|
||||
}
|
||||
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&idx.join(","));
|
||||
let _ = index.set_password(&json_idx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -41,17 +42,35 @@ impl SecretStore for KeyringStore {
|
||||
|
||||
fn remove(&self, key: &str) -> Result<bool> {
|
||||
let entry = keyring::Entry::new(SERVICE, key)?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => Ok(true),
|
||||
Err(keyring::Error::NoEntry) => Ok(false),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
let existed = match entry.delete_credential() {
|
||||
Ok(()) => true,
|
||||
Err(keyring::Error::NoEntry) => false,
|
||||
Err(e) => return Err(anyhow!("keyring: {e}")),
|
||||
};
|
||||
// Always purge the index: it may hold stale names (e.g. a credential
|
||||
// already gone, or leftovers from an older version of remove()).
|
||||
let mut idx = self.list().unwrap_or_default();
|
||||
if idx.iter().any(|k| k == key) {
|
||||
idx.retain(|k| k != key);
|
||||
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&json_idx);
|
||||
}
|
||||
Ok(existed)
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
match index.get_password() {
|
||||
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
|
||||
Ok(v) => {
|
||||
if let Ok(vec) = serde_json::from_str::<Vec<String>>(&v) {
|
||||
Ok(vec)
|
||||
} else if v.contains('\0') {
|
||||
Ok(v.split('\0').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||
} else {
|
||||
Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||
}
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
}
|
||||
@@ -68,12 +87,28 @@ pub fn key_for(agent: &str, name: &str) -> String {
|
||||
format!("{agent}/{name}")
|
||||
}
|
||||
|
||||
/// Key for a provider-scoped secret: "providers/openai/ORG_ID" (issue #89).
|
||||
pub fn key_for_provider(provider: &str, var: &str) -> String {
|
||||
format!("providers/{provider}/{var}")
|
||||
}
|
||||
|
||||
/// Canonical key of a provider token: "providers/openai/api_key"
|
||||
/// (issue #89) — set with `am providers set-token openai`.
|
||||
pub fn key_for_provider_token(provider: &str) -> String {
|
||||
key_for_provider(provider, "api_key")
|
||||
}
|
||||
|
||||
/// Resolve "@secret" placeholders in an env map: a value equal to
|
||||
/// "@secret" pulls the secret named by the variable name for this agent.
|
||||
/// Returns human warnings (names only — values never leak).
|
||||
/// "@secret" pulls the secret named by the variable name for this agent,
|
||||
/// falling back to the provider-scoped secrets of `provider` (issue #89):
|
||||
/// 1. `agent/<VAR>` (agent-scoped, historically the only one)
|
||||
/// 2. `providers/<provider>/<VAR>` (provider-scoped secondary keys)
|
||||
/// 3. `providers/<provider>/api_key` (the provider's canonical token)
|
||||
/// Missing secrets produce warnings with names only — values never leak.
|
||||
pub fn resolve_env_secrets(
|
||||
store: &dyn SecretStore,
|
||||
agent: &str,
|
||||
provider: Option<&str>,
|
||||
env: &mut BTreeMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
@@ -83,15 +118,29 @@ pub fn resolve_env_secrets(
|
||||
if !placeholder {
|
||||
continue;
|
||||
}
|
||||
let key = key_for(agent, &k);
|
||||
match store.get(&key) {
|
||||
Ok(Some(v)) => {
|
||||
let mut value = store.get(&key_for(agent, &k)).ok().flatten();
|
||||
if value.is_none() {
|
||||
if let Some(p) = provider {
|
||||
if value.is_none() {
|
||||
value = store.get(&key_for_provider(p, &k)).ok().flatten();
|
||||
}
|
||||
if value.is_none() {
|
||||
value = store.get(&key_for_provider_token(p)).ok().flatten();
|
||||
}
|
||||
}
|
||||
}
|
||||
match value {
|
||||
Some(v) => {
|
||||
env.insert(k.clone(), v);
|
||||
}
|
||||
Ok(None) => warnings.push(format!(
|
||||
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
|
||||
)),
|
||||
Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")),
|
||||
None => warnings.push(match provider {
|
||||
Some(p) => format!(
|
||||
"no secret for {k} — use: am secret set {k} --agent {agent} --value ... or am providers set-token {p}"
|
||||
),
|
||||
None => format!(
|
||||
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
warnings
|
||||
@@ -141,11 +190,63 @@ mod tests {
|
||||
env.insert("TOKEN".to_string(), "@secret".to_string());
|
||||
env.insert("PLAIN".to_string(), "kept".to_string());
|
||||
env.insert("MISSING".to_string(), "@secret".to_string());
|
||||
let warnings = resolve_env_secrets(&s, "aider", &mut env);
|
||||
let warnings = resolve_env_secrets(&s, "aider", None, &mut env);
|
||||
assert_eq!(env.get("TOKEN").map(String::as_str), Some("secret-value"));
|
||||
assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept"));
|
||||
assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret"));
|
||||
assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}");
|
||||
assert!(!warnings[0].contains("secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_agent_then_provider_var_then_provider_token() {
|
||||
let s = MockStore::default();
|
||||
// Étape 2 : variable scoped provider (clé secondaire).
|
||||
s.set(&key_for_provider("openai", "ORG_ID"), "org-7").unwrap();
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("ORG_ID".to_string(), "@secret".to_string());
|
||||
let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env);
|
||||
assert_eq!(env.get("ORG_ID").map(String::as_str), Some("org-7"));
|
||||
assert!(warnings.is_empty(), "no warning: {warnings:?}");
|
||||
|
||||
// Étape 3 : token canonique du provider (api_key) pour une variable
|
||||
// d'API classique, sans secret par-agent.
|
||||
s.set(&key_for_provider_token("openai"), "sk-provider").unwrap();
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("OPENAI_API_KEY".to_string(), "@secret".to_string());
|
||||
let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env);
|
||||
assert_eq!(env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-provider"));
|
||||
assert!(warnings.is_empty(), "no warning: {warnings:?}");
|
||||
|
||||
// Étape 1 prioritaire : le secret par-agent écrase le token provider.
|
||||
s.set("aider/OPENAI_API_KEY", "sk-agent").unwrap();
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("OPENAI_API_KEY".to_string(), "@secret".to_string());
|
||||
let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env);
|
||||
assert_eq!(env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-agent"));
|
||||
assert!(warnings.is_empty(), "no warning: {warnings:?}");
|
||||
|
||||
// Aucun secret connu (agent ni provider, y compris le token canonique
|
||||
// du provider) : warning qui mentionne la variable et le provider,
|
||||
// jamais la valeur.
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("ANTHROPIC_API_KEY".to_string(), "@secret".to_string());
|
||||
let warnings = resolve_env_secrets(&s, "aider", Some("deepseek"), &mut env);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("ANTHROPIC_API_KEY"));
|
||||
assert!(warnings[0].contains("set-token deepseek"));
|
||||
assert!(!warnings[0].contains("sk-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_keys_never_hold_agent_values() {
|
||||
assert_eq!(key_for("cc", "TOKEN"), "cc/TOKEN");
|
||||
assert_eq!(key_for_provider("openai", "TOKEN"), "providers/openai/TOKEN");
|
||||
assert_eq!(
|
||||
key_for_provider_token("deepseek"),
|
||||
"providers/deepseek/api_key"
|
||||
);
|
||||
// Les deux namespaces restent distincts.
|
||||
assert_ne!(key_for("openai", "api_key"), key_for_provider_token("openai"));
|
||||
}
|
||||
}
|
||||
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
//! am serve (issue #80): authenticated HTTP + WebSocket API to drive am
|
||||
//! remotely. Zero new runtime dependency (tiny_http is already there).
|
||||
//!
|
||||
//! - Every route requires the bearer token (Authorization: Bearer <token>,
|
||||
//! or ?token= for WebSocket clients which cannot set headers).
|
||||
//! - Commands reuse the existing `--json` contracts by re-spawning the am
|
||||
//! binary (the same pattern as the web dashboard, issue #54): stats,
|
||||
//! sessions, ps, run, start, stop, ask.
|
||||
//! - The WebSocket broadcasts journal events (journal, sessions, alerts)
|
||||
//! in real time by polling the events journal.
|
||||
//! - Rate limiting: a sliding window per client IP.
|
||||
//! - TLS is delegated to a reverse proxy (caddy/nginx) — documented.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine;
|
||||
use sha2::Digest;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Default listening port.
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
/// Default per-IP request budget per minute.
|
||||
pub const DEFAULT_RATE_LIMIT: u32 = 120;
|
||||
/// WebSocket GUID mandated by RFC 6455.
|
||||
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
pub struct ServeState {
|
||||
pub token: String,
|
||||
pub rate_limit_per_min: u32,
|
||||
hits: Mutex<HashMap<String, Vec<Instant>>>,
|
||||
pub clients: Mutex<Vec<(usize, Sender<String>)>>,
|
||||
pub events_offsets: Mutex<HashMap<PathBuf, u64>>,
|
||||
next_client_id: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl ServeState {
|
||||
pub fn new(token: &str, rate_limit_per_min: u32) -> Self {
|
||||
ServeState {
|
||||
token: token.to_string(),
|
||||
rate_limit_per_min,
|
||||
hits: Mutex::new(HashMap::new()),
|
||||
clients: Mutex::new(Vec::new()),
|
||||
events_offsets: Mutex::new(HashMap::new()),
|
||||
next_client_id: std::sync::atomic::AtomicUsize::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time-ish token check (no early length shortcut).
|
||||
pub fn token_ok(&self, candidate: &str) -> bool {
|
||||
let a = self.token.as_bytes();
|
||||
let b = candidate.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
/// Sliding window rate limit: returns true when the request is allowed.
|
||||
pub fn allow(&self, ip: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut hits = self.hits.lock().unwrap();
|
||||
let window = hits.entry(ip.to_string()).or_default();
|
||||
window.retain(|t| now.duration_since(*t) < Duration::from_secs(60));
|
||||
if window.len() >= self.rate_limit_per_min as usize {
|
||||
return false;
|
||||
}
|
||||
window.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an am command by re-spawning the current binary (isolated App,
|
||||
/// same guarantees as the CLI). Returns stdout when the exit code is 0.
|
||||
pub fn run_am(config: &Option<PathBuf>, args: &[&str]) -> Result<(i32, String)> {
|
||||
let exe = std::env::current_exe().context("cannot locate the am binary")?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
if let Some(cfg) = config {
|
||||
cmd.arg("--config").arg(cfg);
|
||||
}
|
||||
cmd.arg("--no-color");
|
||||
cmd.args(args);
|
||||
let out = cmd
|
||||
.output()
|
||||
.with_context(|| format!("failed to run {}", exe.display()))?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
Ok((out.status.code().unwrap_or(1), text))
|
||||
}
|
||||
|
||||
/// Reusable test seam: the handler calls this for every command route.
|
||||
pub type Runner = dyn Fn(&[&str]) -> Result<(i32, String)> + Send + Sync;
|
||||
|
||||
/// Serve until the process is killed. Every request is handled on its own
|
||||
/// thread; the broadcast loop pushes journal events to WebSocket clients.
|
||||
pub fn run(app: &App, token: &str, host: &str, port: u16, rate_limit: u32) -> Result<i32> {
|
||||
let server = tiny_http::Server::http((host, port))
|
||||
.map_err(|e| anyhow!("cannot listen on {host}:{port}: {e}"))?;
|
||||
let state = Arc::new(ServeState::new(token, rate_limit));
|
||||
let config = Arc::new(app.cli.config.clone());
|
||||
|
||||
// Broadcast loop: poll the journal, push new events to WS clients.
|
||||
{
|
||||
let state = state.clone();
|
||||
let events_dir = app.events_dir();
|
||||
std::thread::spawn(move || loop {
|
||||
broadcast_events(&state, &events_dir);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
});
|
||||
}
|
||||
|
||||
app.log.info(&format!(
|
||||
"am serve sur http://{host}:{port} (token requis, {} req/min/IP) — Ctrl+C pour arrêter",
|
||||
rate_limit
|
||||
));
|
||||
let runner: Arc<Runner> = Arc::new({
|
||||
let config = config.clone();
|
||||
Box::new(move |args: &[&str]| run_am(config.as_ref(), args))
|
||||
});
|
||||
for request in server.incoming_requests() {
|
||||
let state = state.clone();
|
||||
let config = config.clone();
|
||||
let runner = runner.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = handle(request, &state, config.as_ref(), runner.as_ref());
|
||||
});
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Route table. `runner` is the command executor (re-spawn in production,
|
||||
/// a mock in tests).
|
||||
pub fn handle(
|
||||
mut request: tiny_http::Request,
|
||||
state: &ServeState,
|
||||
config: &Option<PathBuf>,
|
||||
runner: &Runner,
|
||||
) -> Result<()> {
|
||||
let ip = request
|
||||
.remote_addr()
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
if !state.allow(&ip) {
|
||||
return json(request, 429, r#"{"error":"rate limit exceeded"}"#);
|
||||
}
|
||||
let url = request.url().to_string();
|
||||
let (path, query) = match url.split_once('?') {
|
||||
Some((p, q)) => (p.to_string(), q.to_string()),
|
||||
None => (url, String::new()),
|
||||
};
|
||||
|
||||
// Authentication: Authorization: Bearer <token>, or ?token= (WebSocket
|
||||
// clients and simple clients).
|
||||
let auth_ok = header_token(&request)
|
||||
.or_else(|| query_token(&query))
|
||||
.map(|t| state.token_ok(&t))
|
||||
.unwrap_or(false);
|
||||
if !auth_ok {
|
||||
return json(request, 401, r#"{"error":"token required"}"#);
|
||||
}
|
||||
|
||||
match (request.method(), path.as_str()) {
|
||||
(tiny_http::Method::Get, "/api/health") => json(
|
||||
request,
|
||||
200,
|
||||
&format!(
|
||||
r#"{{"ok":true,"version":"{}","time":"{}"}}"#,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::installers::now_rfc3339()
|
||||
),
|
||||
),
|
||||
(tiny_http::Method::Get, "/api/stats") => {
|
||||
command(request, config, runner, &["stats", "--json"])
|
||||
}
|
||||
(tiny_http::Method::Get, "/api/sessions") => {
|
||||
command(request, config, runner, &["sessions", "--json"])
|
||||
}
|
||||
(tiny_http::Method::Get, "/api/ps") => command(request, config, runner, &["ps", "--json"]),
|
||||
(tiny_http::Method::Get, "/api/providers") => {
|
||||
command(request, config, runner, &["providers", "list", "--json"])
|
||||
}
|
||||
(tiny_http::Method::Post, "/api/run") => {
|
||||
let body = read_body(&mut request);
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => {
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
v.get("agent")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
];
|
||||
if let Some(m) = v.get("model").and_then(|m| m.as_str()) {
|
||||
args.push("--model".to_string());
|
||||
args.push(m.to_string());
|
||||
}
|
||||
if let Some(p) = v.get("provider").and_then(|p| p.as_str()) {
|
||||
args.push("--provider".to_string());
|
||||
args.push(p.to_string());
|
||||
}
|
||||
if let Some(extra) = v.get("args").and_then(|a| a.as_array()) {
|
||||
for a in extra {
|
||||
if let Some(s) = a.as_str() {
|
||||
args.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
args.push("--json".to_string());
|
||||
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
command(request, config, runner, &refs)
|
||||
}
|
||||
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
|
||||
}
|
||||
}
|
||||
(tiny_http::Method::Post, "/api/start") => {
|
||||
let body = read_body(&mut request);
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => {
|
||||
let agent = v.get("agent").and_then(|a| a.as_str()).unwrap_or_default();
|
||||
command(
|
||||
request,
|
||||
config,
|
||||
runner,
|
||||
&["start", agent, "--background", "--json"],
|
||||
)
|
||||
}
|
||||
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
|
||||
}
|
||||
}
|
||||
(tiny_http::Method::Post, "/api/stop") => {
|
||||
let body = read_body(&mut request);
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => {
|
||||
let agent = v.get("agent").and_then(|a| a.as_str()).unwrap_or_default();
|
||||
command(request, config, runner, &["stop", agent, "--json"])
|
||||
}
|
||||
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
|
||||
}
|
||||
}
|
||||
(tiny_http::Method::Post, "/api/ask") => {
|
||||
let body = read_body(&mut request);
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => {
|
||||
let q = v.get("query").and_then(|a| a.as_str()).unwrap_or_default();
|
||||
command(request, config, runner, &["ask", q, "--yes"])
|
||||
}
|
||||
Err(_) => json(request, 400, r#"{"error":"invalid JSON body"}"#),
|
||||
}
|
||||
}
|
||||
(tiny_http::Method::Get, "/ws") => websocket(request, state),
|
||||
_ => json(request, 404, r#"{"error":"not found"}"#),
|
||||
}
|
||||
}
|
||||
|
||||
fn header_token(request: &tiny_http::Request) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Authorization"))
|
||||
.and_then(|h| h.value.as_str().strip_prefix("Bearer "))
|
||||
.map(|t| t.trim().to_string())
|
||||
}
|
||||
|
||||
fn query_token(query: &str) -> Option<String> {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("token="))
|
||||
.map(|t| t.to_string())
|
||||
}
|
||||
|
||||
fn command(
|
||||
request: tiny_http::Request,
|
||||
config: &Option<PathBuf>,
|
||||
runner: &Runner,
|
||||
args: &[&str],
|
||||
) -> Result<()> {
|
||||
match runner(args) {
|
||||
Ok((0, out)) => json(request, 200, &out),
|
||||
Ok((code, out)) => json(
|
||||
request,
|
||||
422,
|
||||
&format!(
|
||||
r#"{{"error":"command failed ({code})","output":{}}}"#,
|
||||
serde_json::to_string(&out).unwrap_or_else(|_| "\"\"".into())
|
||||
),
|
||||
),
|
||||
Err(e) => json(
|
||||
request,
|
||||
500,
|
||||
&format!(
|
||||
r#"{{"error":{}}}"#,
|
||||
serde_json::to_string(&format!("{e:#}")).unwrap_or_else(|_| "\"internal\"".into())
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn json(request: tiny_http::Request, status: u32, body: &str) -> Result<()> {
|
||||
let response = tiny_http::Response::from_string(body.to_string())
|
||||
.with_status_code(status)
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
|
||||
);
|
||||
request.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_body(request: &mut tiny_http::Request) -> String {
|
||||
let mut body = String::new();
|
||||
let _ = request
|
||||
.as_reader()
|
||||
.take(1_000_000)
|
||||
.read_to_string(&mut body);
|
||||
body
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket (RFC 6455): handshake + text frames + journal broadcast.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serve the /ws endpoint: upgrade the request, register the client and push
|
||||
/// broadcast frames until the client disconnects.
|
||||
pub fn websocket(request: tiny_http::Request, state: &ServeState) -> Result<()> {
|
||||
let key = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Sec-WebSocket-Key"))
|
||||
.map(|h| h.value.as_str().to_string())
|
||||
.ok_or_else(|| anyhow!("missing Sec-WebSocket-Key"))?;
|
||||
let accept = ws_accept(&key);
|
||||
|
||||
let response = tiny_http::Response::empty(101).with_header(
|
||||
tiny_http::Header::from_bytes(&b"Sec-WebSocket-Accept"[..], accept.as_bytes()).unwrap(),
|
||||
);
|
||||
let mut writer = request.upgrade("websocket", response);
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
let client_id = state
|
||||
.next_client_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
state.clients.lock().unwrap().push((client_id, tx));
|
||||
|
||||
// Write loop: frames come from the broadcast channel; a failed write
|
||||
// means the client is gone. (Pings/close frames are not answered in
|
||||
// this version — the write failure on the closed socket ends the loop.)
|
||||
while let Ok(frame) = rx.recv_timeout(Duration::from_millis(500)) {
|
||||
if write_frame(writer.as_mut(), 1, frame.as_bytes()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The client is gone: remove our sender from the broadcast list.
|
||||
drop(rx);
|
||||
state
|
||||
.clients
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(id, _)| *id != client_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ws_accept(key: &str) -> String {
|
||||
let mut hasher = sha1::Sha1::new();
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.update(WS_GUID.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Write a single server frame (unmasked): opcode + RFC 6455 length + payload.
|
||||
fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> {
|
||||
let len = payload.len();
|
||||
if len <= 125 {
|
||||
w.write_all(&[0x80 | opcode, len as u8])?;
|
||||
} else if len <= 65535 {
|
||||
w.write_all(&[0x80 | opcode, 126])?;
|
||||
w.write_all(&(len as u16).to_be_bytes())?;
|
||||
} else {
|
||||
w.write_all(&[0x80 | opcode, 127])?;
|
||||
w.write_all(&(len as u64).to_be_bytes())?;
|
||||
}
|
||||
w.write_all(payload)?;
|
||||
w.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast the new journal events to every WebSocket client.
|
||||
pub fn broadcast_events(state: &ServeState, events_dir: &std::path::Path) {
|
||||
let files = crate::events::journal_files(events_dir);
|
||||
let Some(latest) = files.last() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut file) = std::fs::File::open(latest) else {
|
||||
return;
|
||||
};
|
||||
let mut offsets = state.events_offsets.lock().unwrap();
|
||||
let known = offsets.contains_key(latest);
|
||||
let start = offsets.get(latest).copied().unwrap_or(0);
|
||||
use std::io::Seek;
|
||||
if file.seek(std::io::SeekFrom::Start(start)).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut tail = String::new();
|
||||
if file.read_to_string(&mut tail).is_err() {
|
||||
return;
|
||||
}
|
||||
let new_len = start + tail.len() as u64;
|
||||
if tail.is_empty() {
|
||||
return;
|
||||
}
|
||||
offsets.insert(latest.clone(), new_len);
|
||||
drop(offsets);
|
||||
// First pass only indexes the journal: nothing is broadcast until the
|
||||
// next poll sees new lines.
|
||||
if !known {
|
||||
return;
|
||||
}
|
||||
let mut clients = state.clients.lock().unwrap();
|
||||
for line in tail.lines() {
|
||||
let frame = format!("{line}\n");
|
||||
for (_, client) in clients.iter() {
|
||||
let _ = client.send(frame.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
|
||||
fn test_state() -> ServeState {
|
||||
ServeState::new("s3cr3t", 3)
|
||||
}
|
||||
|
||||
fn mock_runner(captured: &Arc<Mutex<Vec<String>>>) -> Box<Runner> {
|
||||
let captured = captured.clone();
|
||||
Box::new(move |args: &[&str]| {
|
||||
captured.lock().unwrap().push(args.join(" "));
|
||||
Ok((0, r#"{"mocked":true}"#.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Drive a real tiny_http request through handle() and return the body.
|
||||
fn hit(port: u16, method: &str, path: &str, token: Option<&str>) -> (u32, String) {
|
||||
let url = format!("http://127.0.0.1:{port}{path}");
|
||||
let client = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build();
|
||||
let mut req = client.request(method, &url);
|
||||
if let Some(t) = token {
|
||||
req = req.set("Authorization", &format!("Bearer {t}"));
|
||||
}
|
||||
let res = req.call();
|
||||
match res {
|
||||
Ok(r) => (r.status() as u32, r.into_string().unwrap_or_default()),
|
||||
Err(ureq::Error::Status(code, r)) => (code as u32, r.into_string().unwrap_or_default()),
|
||||
Err(_) => (0, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_token_is_401() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let handle_thread = std::thread::spawn(move || {
|
||||
let req = server
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = test_state();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let runner = mock_runner(&captured);
|
||||
handle(req, &state, &None, runner.as_ref()).unwrap();
|
||||
});
|
||||
let (status, body) = hit(port, "GET", "/api/health", None);
|
||||
handle_thread.join().unwrap();
|
||||
assert_eq!(status, 401);
|
||||
assert!(body.contains("token required"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_token_is_401_and_right_token_health_ok() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let handle_thread = std::thread::spawn(move || {
|
||||
for _ in 0..2 {
|
||||
let req = server
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = test_state();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let runner = mock_runner(&captured);
|
||||
handle(req, &state, &None, runner.as_ref()).unwrap();
|
||||
}
|
||||
});
|
||||
let (s1, _) = hit(port, "GET", "/api/health", Some("wrong"));
|
||||
let (s2, body) = hit(port, "GET", "/api/health", Some("s3cr3t"));
|
||||
handle_thread.join().unwrap();
|
||||
assert_eq!(s1, 401);
|
||||
assert_eq!(s2, 200);
|
||||
assert!(body.contains("\"ok\":true"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_route_invokes_the_runner_with_json() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured2 = captured.clone();
|
||||
let handle_thread = std::thread::spawn(move || {
|
||||
let req = server
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = test_state();
|
||||
let runner = mock_runner(&captured2);
|
||||
handle(req, &state, &None, runner.as_ref()).unwrap();
|
||||
});
|
||||
let (status, body) = hit(port, "GET", "/api/stats", Some("s3cr3t"));
|
||||
handle_thread.join().unwrap();
|
||||
assert_eq!(status, 200);
|
||||
assert!(body.contains("mocked"), "{body}");
|
||||
assert_eq!(captured.lock().unwrap()[0], "stats --json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_route_passes_agent_model_and_args() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured2 = captured.clone();
|
||||
let handle_thread = std::thread::spawn(move || {
|
||||
let req = server
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = test_state();
|
||||
let runner = mock_runner(&captured2);
|
||||
handle(req, &state, &None, runner.as_ref()).unwrap();
|
||||
});
|
||||
let body = r#"{"agent":"claude-code","model":"claude-sonnet-4-5","args":["--help"]}"#;
|
||||
let client = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build();
|
||||
let res = client
|
||||
.post(&format!("http://127.0.0.1:{port}/api/run"))
|
||||
.set("Authorization", "Bearer s3cr3t")
|
||||
.send_string(body)
|
||||
.unwrap();
|
||||
let status = res.status();
|
||||
handle_thread.join().unwrap();
|
||||
assert_eq!(status, 200);
|
||||
let called = captured.lock().unwrap()[0].clone();
|
||||
assert!(called.contains("run claude-code"), "{called}");
|
||||
assert!(called.contains("--model claude-sonnet-4-5"), "{called}");
|
||||
assert!(called.contains("--help"), "{called}");
|
||||
assert!(called.ends_with("--json"), "{called}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_route_is_404() {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let handle_thread = std::thread::spawn(move || {
|
||||
let req = server
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = test_state();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let runner = mock_runner(&captured);
|
||||
handle(req, &state, &None, runner.as_ref()).unwrap();
|
||||
});
|
||||
let (status, body) = hit(port, "GET", "/api/nope", Some("s3cr3t"));
|
||||
handle_thread.join().unwrap();
|
||||
assert_eq!(status, 404);
|
||||
assert!(body.contains("not found"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_blocks_after_the_budget() {
|
||||
let state = test_state(); // budget = 3/min
|
||||
assert!(state.allow("10.0.0.1"));
|
||||
assert!(state.allow("10.0.0.1"));
|
||||
assert!(state.allow("10.0.0.1"));
|
||||
assert!(!state.allow("10.0.0.1"), "4th hit must be blocked");
|
||||
// A different IP is unaffected.
|
||||
assert!(state.allow("10.0.0.2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_handshake_accept_is_rfc6455() {
|
||||
// Known RFC 6455 example: key "dGhlIHNhbXBsZSBub25jZQ=="
|
||||
// -> accept "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
assert_eq!(
|
||||
ws_accept("dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_pushes_new_journal_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = test_state();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
state.clients.lock().unwrap().push((1, tx));
|
||||
let file = dir.path().join("events-202608.jsonl");
|
||||
std::fs::write(&file, "{\"kind\":\"start\"}\n").unwrap();
|
||||
// First pass indexes the existing line (no broadcast).
|
||||
broadcast_events(&state, dir.path());
|
||||
// New line -> broadcast.
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&file)
|
||||
.unwrap();
|
||||
writeln!(f, "{{\"kind\":\"run\"}}").unwrap();
|
||||
broadcast_events(&state, dir.path());
|
||||
let msg = rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
assert!(msg.contains("\"kind\":\"run\""), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_compare_is_exact() {
|
||||
let state = test_state();
|
||||
assert!(state.token_ok("s3cr3t"));
|
||||
assert!(!state.token_ok("s3cr3tX"));
|
||||
assert!(!state.token_ok(""));
|
||||
assert!(!state.token_ok("S3CR3T"));
|
||||
}
|
||||
}
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
//! am setup — the onboarding wizard (épique v1.1.0, F1).
|
||||
//!
|
||||
//! Guides the user through: provider choice, masked API-token entry (OS
|
||||
//! keyring, never in clear), default model selection, a non-blocking API
|
||||
//! ping, the optional aichat installation (F2) and the generation of the
|
||||
//! am-* copilot roles for aichat (F3). Re-runnable at any time (`am setup`
|
||||
//! = "review" mode); triggered on first run by the AI commands and the
|
||||
//! REPL banner when no provider is configured.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::ProvidersCmd;
|
||||
use crate::commands::{config_cmd, install_cmd, providers_cmd};
|
||||
use crate::secrets::SecretStore;
|
||||
use anyhow::{bail, Result};
|
||||
use std::io::Write;
|
||||
|
||||
/// Providers offered by the wizard: every provider defined in the merged
|
||||
/// registry (embedded + user config, sorted), plus the local Ollama runtime
|
||||
/// and a custom endpoint. Built dynamically so new registry providers
|
||||
/// (e.g. the QwenCloud token-plan / pay-as-you-go pair) show up without a
|
||||
/// code change — CHOICES used to be a hardcoded list that silently drifted.
|
||||
fn choices(app: &App) -> Vec<String> {
|
||||
let mut v: Vec<String> = crate::providers::defined(&app.config)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
v.sort();
|
||||
if !v.iter().any(|p| p == "ollama") {
|
||||
v.push("ollama".to_string());
|
||||
}
|
||||
v.push("custom".to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// True when the user has not configured a usable default provider yet:
|
||||
/// no `settings.default_provider`, or the default provider has no API
|
||||
/// token in the keyring (except local Ollama, which needs none).
|
||||
pub fn needs_setup(app: &App) -> bool {
|
||||
let Some(def) = crate::providers::default_name(&app.config) else {
|
||||
return true;
|
||||
};
|
||||
if def == "ollama" {
|
||||
return false;
|
||||
}
|
||||
let store = crate::secrets::store();
|
||||
store
|
||||
.get(&crate::secrets::key_for_provider_token(def))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
}
|
||||
|
||||
/// Read one trimmed line from stdin. `None` on EOF — callers must not spin
|
||||
/// on empty input when nothing is connected (pipes, --yes in CI).
|
||||
fn read_line() -> Result<Option<String>> {
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line)? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(line.trim().to_string()))
|
||||
}
|
||||
|
||||
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
|
||||
fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
|
||||
if app.cli.yes {
|
||||
return Ok(true);
|
||||
}
|
||||
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
|
||||
eprint!("{question} {suffix} ");
|
||||
std::io::stderr().flush()?;
|
||||
let answer = read_line()?.unwrap_or_default().to_lowercase();
|
||||
Ok(match answer.as_str() {
|
||||
"" => default_yes,
|
||||
"y" | "yes" | "o" | "oui" | "true" => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick a provider from the list (or the current default when --yes).
|
||||
fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
|
||||
let current = crate::providers::default_name(&app.config);
|
||||
let choices = choices(app);
|
||||
println!("Provider disponible :");
|
||||
for (i, p) in choices.iter().enumerate() {
|
||||
let star = if Some(p.as_str()) == current { " (actuel)" } else { "" };
|
||||
println!(" {}) {}{star}", i + 1, p);
|
||||
}
|
||||
if app.cli.yes {
|
||||
let name = current.unwrap_or("anthropic").to_string();
|
||||
return Ok((name, None));
|
||||
}
|
||||
loop {
|
||||
print!("Choisissez (1-{}) : ", choices.len());
|
||||
std::io::stdout().flush()?;
|
||||
let Some(raw) = read_line()? else {
|
||||
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
|
||||
};
|
||||
if let Ok(n) = raw.parse::<usize>() {
|
||||
if (1..=choices.len()).contains(&n) {
|
||||
let name = &choices[n - 1];
|
||||
if name == "custom" {
|
||||
print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
|
||||
std::io::stdout().flush()?;
|
||||
let url = read_line()?.unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return Ok(("custom".to_string(), Some(url)));
|
||||
}
|
||||
return Ok((name.to_string(), None));
|
||||
}
|
||||
}
|
||||
if !raw.is_empty() {
|
||||
return Ok((raw, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The declared models of a provider (embedded registry), or an empty list
|
||||
/// for local/custom providers.
|
||||
fn declared_models(app: &App, name: &str) -> Vec<String> {
|
||||
crate::providers::get(&app.config, name)
|
||||
.map(|d| d.models.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the default model: numbered list of the declared models plus a
|
||||
/// free entry. `--yes` keeps the provider's current default.
|
||||
fn pick_model(app: &App, name: &str) -> Result<String> {
|
||||
let models = declared_models(app, name);
|
||||
let current = crate::providers::get(&app.config, name)
|
||||
.and_then(|d| d.default_model.clone())
|
||||
.or_else(|| models.first().cloned());
|
||||
if !models.is_empty() {
|
||||
println!("Modèles disponibles pour {name} :");
|
||||
for (i, m) in models.iter().enumerate() {
|
||||
let star = if Some(m) == current.as_ref() { " (défaut)" } else { "" };
|
||||
println!(" {}) {m}{star}", i + 1);
|
||||
}
|
||||
println!(" {}) autre modèle…", models.len() + 1);
|
||||
} else {
|
||||
println!("Aucun modèle déclaré pour {name} — saisissez le modèle par défaut.");
|
||||
}
|
||||
if app.cli.yes {
|
||||
return Ok(current.unwrap_or_else(|| "gpt-4o".to_string()));
|
||||
}
|
||||
loop {
|
||||
print!("Modèle par défaut : ");
|
||||
std::io::stdout().flush()?;
|
||||
let Some(raw) = read_line()? else {
|
||||
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
|
||||
};
|
||||
if let Ok(n) = raw.parse::<usize>() {
|
||||
if n >= 1 && n <= models.len() {
|
||||
return Ok(models[n - 1].clone());
|
||||
}
|
||||
if n == models.len() + 1 {
|
||||
print!("Nom du modèle : ");
|
||||
std::io::stdout().flush()?;
|
||||
let custom = read_line()?.unwrap_or_default();
|
||||
if !custom.is_empty() {
|
||||
return Ok(custom);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !raw.is_empty() {
|
||||
return Ok(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking API ping: any HTTP answer (200, 401…) proves the endpoint
|
||||
/// is reachable and the token is transmitted. Network failures are logged
|
||||
/// as warnings only — the wizard never blocks on this.
|
||||
fn ping_provider(name: &str, base_url: &str, token: Option<&str>) {
|
||||
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||
let result = match token {
|
||||
Some(t) => ureq::get(&url).set("Authorization", &format!("Bearer {t}")).call(),
|
||||
None => ureq::get(&url).call(),
|
||||
};
|
||||
match result {
|
||||
Ok(_) | Err(ureq::Error::Status(_, _)) => {
|
||||
// Reachable (200 or an auth-status): the endpoint answered.
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠ ping du provider échoué (non bloquant) : {e}");
|
||||
}
|
||||
}
|
||||
let _ = name;
|
||||
}
|
||||
|
||||
/// Main wizard. `--yes` runs it non-interactively with the current
|
||||
/// defaults (install aichat + roles included).
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
let lang = app.lang();
|
||||
if !app.cli.yes {
|
||||
println!("⚙ {}", crate::i18n::tr_in(lang, "Configuration d'agent-manager (am setup)"));
|
||||
println!("{}", crate::i18n::tr_in(lang, "Le token est stocké dans le trousseau OS — jamais en clair."));
|
||||
}
|
||||
|
||||
// 1) Provider.
|
||||
let (provider, custom_url) = pick_provider(app)?;
|
||||
let base_url = if provider == "custom" {
|
||||
custom_url.clone().unwrap_or_default()
|
||||
} else {
|
||||
crate::providers::get(&app.config, &provider)
|
||||
.map(|d| d.base_url.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if provider == "custom" {
|
||||
let cmd = ProvidersCmd::Add {
|
||||
name: "custom".to_string(),
|
||||
base_url: custom_url.clone().unwrap_or_default(),
|
||||
model: None,
|
||||
models: None,
|
||||
};
|
||||
providers_cmd::run(app, Some(&cmd))?;
|
||||
} else if provider == "ollama" {
|
||||
// Local runtime: register it (it is not part of the embedded
|
||||
// registry) so the config stays valid, then keep going tokenless.
|
||||
let cmd = ProvidersCmd::Add {
|
||||
name: "ollama".to_string(),
|
||||
base_url: "http://localhost:11434/v1".to_string(),
|
||||
model: None,
|
||||
models: None,
|
||||
};
|
||||
providers_cmd::run(app, Some(&cmd))?;
|
||||
}
|
||||
|
||||
// 2) Token (masked) — skipped for local Ollama.
|
||||
let mut token: Option<String> = None;
|
||||
if provider != "ollama" {
|
||||
if app.cli.yes {
|
||||
let store = crate::secrets::store();
|
||||
token = store
|
||||
.get(&crate::secrets::key_for_provider_token(&provider))
|
||||
.ok()
|
||||
.flatten();
|
||||
} else {
|
||||
let value = rpassword::prompt_password(&format!(
|
||||
"Token API pour {provider} (invisible) : "
|
||||
))?;
|
||||
if !value.is_empty() {
|
||||
token = Some(value.clone());
|
||||
let cmd = ProvidersCmd::SetToken {
|
||||
name: provider.clone(),
|
||||
value,
|
||||
};
|
||||
providers_cmd::run(app, Some(&cmd))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Default model. The registry lives under settings.providers — a
|
||||
// top-level `providers:` key is rejected by the schema validator, and a
|
||||
// provider block written to the user config requires base_url too.
|
||||
let model = pick_model(app, &provider)?;
|
||||
if provider == "ollama" {
|
||||
// base_url was already written by the Add above.
|
||||
config_cmd::persist_setting(app, "settings.providers.ollama.default_model", &model)?;
|
||||
} else if provider != "custom" {
|
||||
if let Some(def) = crate::providers::get(&app.config, &provider) {
|
||||
config_cmd::persist_setting(
|
||||
app,
|
||||
&format!("settings.providers.{provider}.base_url"),
|
||||
&def.base_url,
|
||||
)?;
|
||||
config_cmd::persist_setting(
|
||||
app,
|
||||
&format!("settings.providers.{provider}.default_model"),
|
||||
&model,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Default provider.
|
||||
config_cmd::persist_setting(app, "settings.default_provider", &provider)?;
|
||||
|
||||
// 5) Non-blocking ping.
|
||||
if provider != "ollama" && !base_url.is_empty() {
|
||||
ping_provider(&provider, &base_url, token.as_deref());
|
||||
}
|
||||
|
||||
// 6) Install aichat (F2) — skipped when already present.
|
||||
let aichat_present = app
|
||||
.state
|
||||
.get("aichat")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| which::which("aichat").is_ok();
|
||||
if !aichat_present {
|
||||
if ask(app, crate::i18n::tr_in(lang, "Installer le moteur IA (aichat) ?"), true)? {
|
||||
println!("{}", crate::i18n::tr_in(lang, "Installation d'aichat…"));
|
||||
install_cmd::run(app, "aichat", None, false, None, None, false)?;
|
||||
}
|
||||
} else {
|
||||
println!("✓ aichat déjà installé");
|
||||
}
|
||||
|
||||
// 7) Copilot roles (F3) + aichat config (only when absent).
|
||||
let roles = crate::roles::generate_all(app)?;
|
||||
let config_created = crate::roles::ensure_config_file(app)?;
|
||||
println!(
|
||||
"✓ {} ({} fichiers)",
|
||||
crate::i18n::tr_in(lang, "Rôles copilot am-* générés"),
|
||||
roles.len()
|
||||
);
|
||||
if config_created {
|
||||
println!("✓ {}", crate::i18n::tr_in(lang, "Config aichat créée (provider + modèle)"));
|
||||
}
|
||||
|
||||
app.log.success(&crate::i18n::tr_in(
|
||||
lang,
|
||||
"am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"",
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// `am setup --roles`: (re)generate only the copilot roles.
|
||||
pub fn run_roles(app: &App) -> Result<i32> {
|
||||
let roles = crate::roles::generate_all(app)?;
|
||||
println!("✓ {} fichiers de rôle :", roles.len());
|
||||
for p in &roles {
|
||||
println!(" {}", p.display());
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use crate::cli::Cli;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn app_with(settings_yaml: &str) -> App {
|
||||
app_with_path(settings_yaml).0
|
||||
}
|
||||
|
||||
fn app_with_path(settings_yaml: &str) -> (App, PathBuf) {
|
||||
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"am-setup-test-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{settings_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
(App::from_cli(cli).unwrap(), cfg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_setup_true_without_a_configured_provider() {
|
||||
// The embedded default ships default_provider: anthropic, but no
|
||||
// keyring token exists in a test process -> setup needed.
|
||||
let app = app_with("");
|
||||
assert!(needs_setup(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_offers_every_registry_provider() {
|
||||
// Regression (v1.1.8): the wizard used a hardcoded CHOICES list and
|
||||
// the QwenCloud providers added to the registry never appeared.
|
||||
// The list must now track the merged registry + ollama + custom.
|
||||
let app = app_with("");
|
||||
let c = choices(&app);
|
||||
for p in [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"deepseek",
|
||||
"google",
|
||||
"openrouter",
|
||||
"qwen-cloud",
|
||||
"qwen-cloud-token-plan",
|
||||
"ollama",
|
||||
"custom",
|
||||
] {
|
||||
assert!(c.iter().any(|x| x == p), "wizard must offer {p}: {c:?}");
|
||||
}
|
||||
// User-defined providers show up too, ollama is never duplicated.
|
||||
let app2 = app_with(
|
||||
" default_provider: anthropic\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n my-llm:\n base_url: https://llm.internal/v1\n",
|
||||
);
|
||||
let c2 = choices(&app2);
|
||||
assert_eq!(c2.iter().filter(|x| *x == "ollama").count(), 1, "{c2:?}");
|
||||
assert!(c2.iter().any(|x| x == "my-llm"), "{c2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_setup_false_for_local_ollama() {
|
||||
let app = app_with(
|
||||
" default_provider: ollama\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n",
|
||||
);
|
||||
assert!(!needs_setup(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_persists_under_settings_providers() {
|
||||
// Regression: a top-level `providers:` key is rejected by the
|
||||
// schema validator ("unknown field `providers`"). The wizard must
|
||||
// write settings.providers.<name>.default_model.
|
||||
let (app, cfg) = app_with_path("");
|
||||
config_cmd::persist_setting(
|
||||
&app,
|
||||
"settings.providers.deepseek.base_url",
|
||||
"https://api.deepseek.com",
|
||||
)
|
||||
.unwrap();
|
||||
config_cmd::persist_setting(
|
||||
&app,
|
||||
"settings.providers.deepseek.default_model",
|
||||
"deepseek-reasoner",
|
||||
)
|
||||
.unwrap();
|
||||
config_cmd::persist_setting(&app, "settings.default_provider", "deepseek").unwrap();
|
||||
|
||||
// Reloading the config must succeed (validation passes) and the
|
||||
// merged registry must reflect the new default model.
|
||||
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app2 = App::from_cli(cli).expect("config must stay valid after setup writes");
|
||||
let def = crate::providers::get(&app2.config, "deepseek").unwrap();
|
||||
assert_eq!(def.default_model.as_deref(), Some("deepseek-reasoner"));
|
||||
assert_eq!(
|
||||
crate::providers::default_name(&app2.config).as_deref(),
|
||||
Some("deepseek")
|
||||
);
|
||||
}
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
//! am ai — langage naturel → action shell (issues #96 #97).
|
||||
//!
|
||||
//! The Shell AI command turns a natural-language request into a shell
|
||||
//! action using AIChat (the `aichat` catalog agent) as the generation
|
||||
//! engine:
|
||||
//!
|
||||
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
|
||||
//! spawned in the foreground, exactly as the user would run it;
|
||||
//! - exec mode (`--exec`): aichat is asked to generate the command with
|
||||
//! `--code` (it prints only the command and never runs it — aichat is
|
||||
//! invoked without `--execute`), then the generated command is
|
||||
//! classified (safe / risky), a confidence score
|
||||
//! is estimated, the configured safety policy is applied (dry-run by
|
||||
//! default), and only then — after confirmation when required — is the
|
||||
//! command executed through the user's shell.
|
||||
//!
|
||||
//! Nothing is ever executed without an explicit confirmation: the default
|
||||
//! policy is `dry-run`, overridden by `--yes` (execute without asking),
|
||||
//! the `settings.shell_ai.default_safety` setting (dry-run | confirm |
|
||||
//! auto) or the explicit `--dry-run` flag (never execute, show only).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::commands::{require_agent, resolve_exec};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::process::Command;
|
||||
|
||||
/// Safety policies of `am ai --exec` (issue #97).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SafetyMode {
|
||||
/// Show the generated command only — nothing is executed unless the
|
||||
/// user passes `--yes`.
|
||||
DryRun,
|
||||
/// Execute safe commands directly; ask before risky ones.
|
||||
Confirm,
|
||||
/// Execute everything without asking (explicitly enabled by the user).
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl SafetyMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SafetyMode::DryRun => "dry-run",
|
||||
SafetyMode::Confirm => "confirm",
|
||||
SafetyMode::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Risk class of a generated shell command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Risk {
|
||||
Safe,
|
||||
Risky,
|
||||
}
|
||||
|
||||
impl Risk {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Risk::Safe => "safe",
|
||||
Risk::Risky => "risky",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in substrings that mark a command as risky (issue #97). The user
|
||||
/// can extend this list with `settings.shell_ai.risky_patterns`.
|
||||
pub const BUILTIN_RISKY_PATTERNS: &[&str] = &[
|
||||
"rm -rf",
|
||||
"rm -fr",
|
||||
"rm -r -f",
|
||||
"dd if=",
|
||||
"mkfs.",
|
||||
"fdisk",
|
||||
"parted",
|
||||
"gdisk",
|
||||
":(){",
|
||||
"chmod -R 777",
|
||||
"chmod 777 /",
|
||||
"chown -R",
|
||||
"> /dev/sd",
|
||||
">/dev/sd",
|
||||
"sudo rm",
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"drop database",
|
||||
"drop table",
|
||||
"truncate table",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"kill -9",
|
||||
"pkill -9",
|
||||
"killall",
|
||||
"init 0",
|
||||
"init 6",
|
||||
"mv / ",
|
||||
"rm /",
|
||||
"format c:",
|
||||
"del /f /s",
|
||||
"rd /s",
|
||||
"cipher /w",
|
||||
"curl ... | sh",
|
||||
"curl ... | bash",
|
||||
"wget ... | sh",
|
||||
];
|
||||
|
||||
/// Classify a generated command against the built-in patterns plus the
|
||||
/// user-configured ones (`settings.shell_ai.risky_patterns`).
|
||||
pub fn classify(cmd: &str, extra_patterns: &[String]) -> Risk {
|
||||
let lower = cmd.to_lowercase();
|
||||
let hits = BUILTIN_RISKY_PATTERNS
|
||||
.iter()
|
||||
.any(|p| lower.contains(&p.to_lowercase()))
|
||||
|| extra_patterns.iter().any(|p| lower.contains(&p.to_lowercase()));
|
||||
if hits {
|
||||
Risk::Risky
|
||||
} else {
|
||||
Risk::Safe
|
||||
}
|
||||
}
|
||||
|
||||
/// A coarse confidence heuristic (0-100) shown to the user. Risky or
|
||||
/// compound commands are scored lower; simple, read-only commands score
|
||||
/// higher. It is an estimate, never a guarantee.
|
||||
pub fn estimate_certainty(cmd: &str, risk: Risk) -> u8 {
|
||||
let mut score: i32 = 92;
|
||||
if risk == Risk::Risky {
|
||||
score -= 25;
|
||||
}
|
||||
// Compound commands chain several effects — harder to predict.
|
||||
for sep in ["&&", "||", ";\n", "\n", " | ", " 2>"] {
|
||||
if cmd.contains(sep) {
|
||||
score -= 6;
|
||||
}
|
||||
}
|
||||
// Redirections and pipes move data around.
|
||||
if cmd.contains('>') || cmd.contains('<') {
|
||||
score -= 5;
|
||||
}
|
||||
if cmd.contains("sudo") {
|
||||
score -= 8;
|
||||
}
|
||||
score.clamp(40, 99) as u8
|
||||
}
|
||||
|
||||
/// Decide what to do with a generated command under the effective policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// Print and abort (dry-run or declined confirmation).
|
||||
Abort,
|
||||
/// Ask the user (y/N) before executing.
|
||||
Ask,
|
||||
/// Execute directly.
|
||||
Execute,
|
||||
}
|
||||
|
||||
/// Apply the safety policy: explicit `--dry-run` flag > `--yes` flag >
|
||||
/// configured default mode (fallback: dry-run).
|
||||
pub fn decide(
|
||||
mode: SafetyMode,
|
||||
risk: Risk,
|
||||
dry_run_flag: bool,
|
||||
yes_flag: bool,
|
||||
) -> Decision {
|
||||
if dry_run_flag {
|
||||
return Decision::Abort;
|
||||
}
|
||||
if yes_flag {
|
||||
return Decision::Execute;
|
||||
}
|
||||
match mode {
|
||||
SafetyMode::Auto => Decision::Execute,
|
||||
SafetyMode::Confirm => {
|
||||
if risk == Risk::Risky {
|
||||
Decision::Ask
|
||||
} else {
|
||||
Decision::Execute
|
||||
}
|
||||
}
|
||||
SafetyMode::DryRun => Decision::Abort,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the shell command from aichat's `--dry-run` output: strips a
|
||||
/// fenced code block if present, otherwise uses the trimmed output as-is.
|
||||
pub fn extract_command(stdout: &str) -> Option<String> {
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Fenced block: ```bash ... ``` (or ```sh, ```powershell, ```cmd ...)
|
||||
let mut lines = trimmed.lines();
|
||||
if lines.next().is_some_and(|l| l.trim_start().starts_with("```")) {
|
||||
let body: Vec<&str> = lines
|
||||
.take_while(|l| !l.trim().starts_with("```"))
|
||||
.collect();
|
||||
let cmd = body.join("\n").trim().to_string();
|
||||
if !cmd.is_empty() {
|
||||
return Some(cmd);
|
||||
}
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Map a provider of the registry to the environment variables AIChat
|
||||
/// understands, and resolve the model to pass with `--model`. The token is
|
||||
/// resolved from the OS keyring via the standard `@secret` mechanism and
|
||||
/// never appears on the command line (issue #89).
|
||||
pub fn provider_env(
|
||||
app: &App,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
|
||||
// Without any flag, aichat keeps its own configuration (its config file
|
||||
// and API keys) — the registry is only applied on explicit request.
|
||||
if provider.is_none() && model.is_none() {
|
||||
return Ok((Vec::new(), BTreeMap::new()));
|
||||
}
|
||||
let Some((pname, def, resolved_model)) =
|
||||
crate::providers::resolve_for(&app.config, None, None, provider, model)
|
||||
else {
|
||||
if provider.is_some() {
|
||||
let known = crate::providers::names(&app.config);
|
||||
let hint = if known.is_empty() {
|
||||
"aucun (am providers add <nom> --base-url <url>)".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
};
|
||||
bail!(crate::i18n::tr_fmt_in(
|
||||
app.lang(),
|
||||
"provider '{}' inconnu — providers enregistrés: {}",
|
||||
&[&provider.unwrap_or(""), &hint]
|
||||
));
|
||||
}
|
||||
return Ok((Vec::new(), BTreeMap::new()));
|
||||
};
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
||||
// Known providers map to AIChat's standard API-key variables; custom
|
||||
// provider names fall back to the openai-compatible channel.
|
||||
let key_var = match pname {
|
||||
"anthropic" => Some("ANTHROPIC_API_KEY"),
|
||||
"openai" => Some("OPENAI_API_KEY"),
|
||||
"deepseek" => Some("DEEPSEEK_API_KEY"),
|
||||
"google" => Some("GOOGLE_API_KEY"),
|
||||
"groq" => Some("GROQ_API_KEY"),
|
||||
"openrouter" => Some("OPENROUTER_API_KEY"),
|
||||
"xai" | "grok" => Some("XAI_API_KEY"),
|
||||
"ollama" => None, // local — no key; aichat knows its default endpoint
|
||||
_ => Some("OPENAI_API_KEY"),
|
||||
};
|
||||
if let Some(var) = key_var {
|
||||
env.insert(var.to_string(), "@secret".to_string());
|
||||
if !matches!(pname, "openai" | "ollama" | "anthropic" | "deepseek" | "google" | "groq" | "openrouter" | "xai" | "grok") {
|
||||
// Custom endpoints ride the openai-compatible channel.
|
||||
env.insert("OPENAI_API_BASE".to_string(), def.base_url.clone());
|
||||
}
|
||||
}
|
||||
if let Some(m) = resolved_model {
|
||||
args.push("--model".to_string());
|
||||
args.push(m.to_string());
|
||||
}
|
||||
let warnings = crate::secrets::resolve_env_secrets(
|
||||
&crate::secrets::store(),
|
||||
"aichat",
|
||||
Some(pname),
|
||||
&mut env,
|
||||
);
|
||||
for w in warnings {
|
||||
app.log.warn(&w);
|
||||
}
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// Generate a shell command for `prompt` using aichat in dry-run mode.
|
||||
/// Returns the generated command (never executed by aichat).
|
||||
pub fn generate(
|
||||
app: &App,
|
||||
prompt: &str,
|
||||
files: &[String],
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
role_args: &[String],
|
||||
) -> Result<String> {
|
||||
let agent = require_agent(app, "aichat")?;
|
||||
// aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to
|
||||
// skip the API call entirely (it echoes the request), so the old
|
||||
// `--execute --dry-run` no longer yields the generated command. Plain
|
||||
// chat with `--code` extracts and prints only the command (same output
|
||||
// shape, works on every aichat version). aichat NEVER executes here —
|
||||
// am enforces the safety policy before running the command itself.
|
||||
let mut extra_args: Vec<String> = vec!["--code".to_string()];
|
||||
extra_args.extend(role_args.iter().cloned());
|
||||
for f in files {
|
||||
extra_args.push("-f".to_string());
|
||||
extra_args.push(f.clone());
|
||||
}
|
||||
extra_args.push(prompt.to_string());
|
||||
let (p_args, p_env) = provider_env(app, provider, model)?;
|
||||
extra_args.extend(p_args);
|
||||
let exec = resolve_exec(app, agent, &extra_args, &p_env)?;
|
||||
app.log.verbose(&format!(
|
||||
"shell-ai: {} {} (dry-run generation)",
|
||||
exec.program,
|
||||
exec.args.join(" ")
|
||||
));
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
let out = Command::new(&prog)
|
||||
.args(&full_args)
|
||||
.envs(&exec.env)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let err = err.trim();
|
||||
bail!(crate::i18n::tr_fmt_in(
|
||||
app.lang(),
|
||||
"aichat n'a pas généré de commande (exit {}){}",
|
||||
&[
|
||||
&out.status.code().unwrap_or(-1).to_string(),
|
||||
&if err.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {err}")
|
||||
},
|
||||
]
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
extract_command(&stdout).ok_or_else(|| {
|
||||
anyhow!(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"aichat n'a retourné aucune commande (dry-run)"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a generated command through the user's shell (default_shell >
|
||||
/// $SHELL > $COMSPEC > cmd). The command is passed as a single argument.
|
||||
pub fn execute(app: &App, command: &str) -> Result<i32> {
|
||||
let spec = crate::shell::resolve_default(
|
||||
app.config.settings.default_shell.as_deref(),
|
||||
std::env::var_os("SHELL"),
|
||||
std::env::var_os("COMSPEC"),
|
||||
);
|
||||
app.log.verbose(&format!(
|
||||
"shell-ai: executing via {} {}",
|
||||
spec.program,
|
||||
spec.args.join(" ")
|
||||
));
|
||||
let (prog, prefix) = crate::runner::resolve_program(spec.program);
|
||||
let mut args = prefix;
|
||||
args.extend(spec.args.iter().map(|s| s.to_string()));
|
||||
args.push(command.to_string());
|
||||
let status = Command::new(&prog)
|
||||
.args(&args)
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", spec.program))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_safe_and_risky() {
|
||||
assert_eq!(classify("ls -la", &[]), Risk::Safe);
|
||||
assert_eq!(classify("echo hello", &[]), Risk::Safe);
|
||||
assert_eq!(classify("find . -name '*.json'", &[]), Risk::Safe);
|
||||
assert_eq!(classify("rm -rf /tmp/x", &[]), Risk::Risky);
|
||||
assert_eq!(classify("sudo rm -rf /var/log", &[]), Risk::Risky);
|
||||
assert_eq!(classify("dd if=/dev/zero of=/dev/sda", &[]), Risk::Risky);
|
||||
assert_eq!(classify("git push --force origin main", &[]), Risk::Risky);
|
||||
assert_eq!(classify("drop database prod;", &[]), Risk::Risky);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_extra_user_patterns() {
|
||||
let extra = vec!["killall node".to_string()];
|
||||
assert_eq!(classify("killall node", &extra), Risk::Risky);
|
||||
assert_eq!(classify("killall nodejs", &extra), Risk::Risky);
|
||||
assert_eq!(classify("node --version", &extra), Risk::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certainty_stays_in_bounds_and_penalizes_risk() {
|
||||
let safe = estimate_certainty("ls -la", Risk::Safe);
|
||||
let risky = estimate_certainty("rm -rf /", Risk::Risky);
|
||||
assert!(safe > risky, "{safe} should be > {risky}");
|
||||
assert!((40..=99).contains(&safe));
|
||||
assert!((40..=99).contains(&risky));
|
||||
let compound = estimate_certainty("rm -rf /tmp/a && echo ok", Risk::Risky);
|
||||
assert!(compound < risky || compound == risky);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_command_handles_fences_and_plain() {
|
||||
assert_eq!(
|
||||
extract_command("```bash\nrm *.tmp\n```"),
|
||||
Some("rm *.tmp".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_command("```sh\necho hello\n```\n"),
|
||||
Some("echo hello".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_command("rm *.tmp\n"),
|
||||
Some("rm *.tmp".to_string())
|
||||
);
|
||||
assert_eq!(extract_command(" \n "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_matrix() {
|
||||
// dry-run (default): abort, even with --yes overriding to execute.
|
||||
assert_eq!(
|
||||
decide(SafetyMode::DryRun, Risk::Risky, false, false),
|
||||
Decision::Abort
|
||||
);
|
||||
assert_eq!(
|
||||
decide(SafetyMode::DryRun, Risk::Safe, false, false),
|
||||
Decision::Abort
|
||||
);
|
||||
assert_eq!(
|
||||
decide(SafetyMode::DryRun, Risk::Risky, false, true),
|
||||
Decision::Execute
|
||||
);
|
||||
// explicit --dry-run wins over --yes.
|
||||
assert_eq!(
|
||||
decide(SafetyMode::Auto, Risk::Risky, true, true),
|
||||
Decision::Abort
|
||||
);
|
||||
// confirm: ask only for risky.
|
||||
assert_eq!(
|
||||
decide(SafetyMode::Confirm, Risk::Safe, false, false),
|
||||
Decision::Execute
|
||||
);
|
||||
assert_eq!(
|
||||
decide(SafetyMode::Confirm, Risk::Risky, false, false),
|
||||
Decision::Ask
|
||||
);
|
||||
// auto: execute everything.
|
||||
assert_eq!(
|
||||
decide(SafetyMode::Auto, Risk::Risky, false, false),
|
||||
Decision::Execute
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_mode_parsing_falls_back_to_dry_run() {
|
||||
use crate::config::ShellAiSettings;
|
||||
let s = ShellAiSettings {
|
||||
default_safety: Some("confirm".to_string()),
|
||||
risky_patterns: vec![],
|
||||
};
|
||||
assert_eq!(s.safety_mode(), SafetyMode::Confirm);
|
||||
let s = ShellAiSettings {
|
||||
default_safety: Some("n'importe quoi".to_string()),
|
||||
risky_patterns: vec![],
|
||||
};
|
||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||
let s = ShellAiSettings {
|
||||
default_safety: None,
|
||||
risky_patterns: vec![],
|
||||
};
|
||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||
}
|
||||
}
|
||||
+23
@@ -31,6 +31,7 @@ backups/
|
||||
.env
|
||||
.env.*
|
||||
secrets*.json
|
||||
providers/
|
||||
id_rsa*
|
||||
"#;
|
||||
|
||||
@@ -196,6 +197,28 @@ mod tests {
|
||||
assert_eq!(again.matches("# managed by agent-manager").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_gitignore_excludes_secret_and_provider_files() {
|
||||
// Issue #89: provider tokens (like agent secrets) must never reach
|
||||
// the sync bundle. The gitignore rules are the guard, since secrets
|
||||
// live in the OS keyring and are never written next to the state.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ensure_gitignore(dir.path()).unwrap();
|
||||
// check-ignore needs a repository on Windows (--no-index is broken
|
||||
// on this git build), so init one like the other sync tests.
|
||||
git(dir.path(), &["init", "-q"]).unwrap();
|
||||
for (path, ignored) in [
|
||||
("secrets.json", true),
|
||||
("providers/tokens.json", true),
|
||||
("providers/openai.api_key", true),
|
||||
("state.json", false),
|
||||
("events-202608.jsonl", false),
|
||||
] {
|
||||
let ok = git(dir.path(), &["check-ignore", path]).is_ok();
|
||||
assert_eq!(ok, ignored, "check-ignore {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_skips_without_repo_setting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Anonymous opt-in telemetry (issue #76): aggregated counters only.
|
||||
//!
|
||||
//! Nothing is collected or sent while `settings.telemetry.enabled` is false
|
||||
//! (the default). When enabled, every emitted event increments a counter by
|
||||
//! kind — never by agent name, path, command or any identifier. The counter
|
||||
//! file lives next to the state file (`telemetry.json`) and is batched to
|
||||
//! the configured endpoint with a simple backoff; failures never block or
|
||||
//! slow down the CLI (fire-and-forget, verbose log only).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::events::Event;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Batch thresholds (issue #76): send when at least 10 events accumulated,
|
||||
/// or when the last attempt is older than 7 days.
|
||||
const BATCH_MIN_EVENTS: u64 = 10;
|
||||
const BATCH_MAX_AGE_DAYS: i64 = 7;
|
||||
/// Give up after 3 consecutive failures until the age threshold passes
|
||||
/// again (exponential-ish backoff without a timer).
|
||||
const MAX_CONSECUTIVE_FAILURES: u32 = 3;
|
||||
|
||||
fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Counters {
|
||||
schema: u32,
|
||||
version: String,
|
||||
first_seen: String,
|
||||
last_seen: String,
|
||||
/// Counters by event kind (aggregated, never per-agent).
|
||||
events: BTreeMap<String, u64>,
|
||||
/// Run/start outcomes, aggregated.
|
||||
runs_succeeded: u64,
|
||||
runs_failed: u64,
|
||||
#[serde(default)]
|
||||
sent_at: Option<String>,
|
||||
#[serde(default)]
|
||||
fail_count: u32,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
fn new(version: &str) -> Self {
|
||||
let now = crate::installers::now_rfc3339();
|
||||
Counters {
|
||||
schema: 1,
|
||||
version: version.to_string(),
|
||||
first_seen: now.clone(),
|
||||
last_seen: now,
|
||||
events: BTreeMap::new(),
|
||||
runs_succeeded: 0,
|
||||
runs_failed: 0,
|
||||
sent_at: None,
|
||||
fail_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn total(&self) -> u64 {
|
||||
self.events.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
fn counters_path(app: &App) -> PathBuf {
|
||||
app.paths
|
||||
.state_file
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join("telemetry.json")
|
||||
}
|
||||
|
||||
fn enabled(app: &App) -> bool {
|
||||
app.config
|
||||
.settings
|
||||
.telemetry
|
||||
.as_ref()
|
||||
.map(|t| t.enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn load(path: &Path, version: &str) -> Counters {
|
||||
match std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Counters>(&t).ok())
|
||||
{
|
||||
Some(mut c) => {
|
||||
c.last_seen = crate::installers::now_rfc3339();
|
||||
c
|
||||
}
|
||||
None => Counters::new(version),
|
||||
}
|
||||
}
|
||||
|
||||
fn save(path: &Path, counters: &Counters) {
|
||||
if let Ok(json) = serde_json::to_string_pretty(counters) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one event into the aggregated counters. No-op while telemetry is
|
||||
/// disabled (nothing is even written locally). Only the kind and the
|
||||
/// run/start outcome are counted — the payload (args, agent, cwd, env keys)
|
||||
/// is deliberately ignored.
|
||||
pub fn maybe_record(app: &App, event: &Event) {
|
||||
if !enabled(app) {
|
||||
return;
|
||||
}
|
||||
let path = counters_path(app);
|
||||
let mut counters = load(&path, version());
|
||||
let kind = event.kind.as_str();
|
||||
*counters.events.entry(kind.to_string()).or_insert(0) += 1;
|
||||
match event.exit_code {
|
||||
Some(0) => counters.runs_succeeded += 1,
|
||||
Some(_) => counters.runs_failed += 1,
|
||||
None => {}
|
||||
}
|
||||
save(&path, &counters);
|
||||
}
|
||||
|
||||
/// Batch flush, called at the end of every CLI run (fire-and-forget):
|
||||
/// sends the aggregated counters when the thresholds are met, with a simple
|
||||
/// backoff on failure. Never blocks: errors are verbose-logged only.
|
||||
pub fn maybe_flush(app: &App) {
|
||||
let Some(telemetry) = &app.config.settings.telemetry else {
|
||||
return;
|
||||
};
|
||||
if !telemetry.enabled {
|
||||
return;
|
||||
}
|
||||
let Some(endpoint) = telemetry.endpoint.as_deref().filter(|e| !e.is_empty()) else {
|
||||
return; // counters stay local
|
||||
};
|
||||
let path = counters_path(app);
|
||||
let counters = load(&path, version());
|
||||
let now = chrono::Utc::now();
|
||||
let age_days = counters
|
||||
.sent_at
|
||||
.as_deref()
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.map(|t| (now - t.with_timezone(&chrono::Utc)).num_days())
|
||||
.unwrap_or(i64::MAX);
|
||||
let due = counters.total() >= BATCH_MIN_EVENTS || age_days >= BATCH_MAX_AGE_DAYS;
|
||||
if !due || counters.fail_count >= MAX_CONSECUTIVE_FAILURES {
|
||||
return;
|
||||
}
|
||||
match send(endpoint, &counters) {
|
||||
Ok(()) => {
|
||||
let mut fresh = Counters::new(version());
|
||||
fresh.sent_at = Some(crate::installers::now_rfc3339());
|
||||
save(&path, &fresh);
|
||||
app.log.verbose("telemetry batch sent (aggregated counters)");
|
||||
}
|
||||
Err(e) => {
|
||||
let mut updated = counters;
|
||||
updated.fail_count += 1;
|
||||
save(&path, &updated);
|
||||
app.log
|
||||
.verbose(&format!("telemetry batch failed (will retry): {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(endpoint: &str, counters: &Counters) -> Result<()> {
|
||||
let body = serde_json::to_string(counters)?;
|
||||
let resp = ureq::post(endpoint)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("User-Agent", &format!("agent-manager/{}", counters.version))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send_string(&body)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Human-readable status (used by tests and future `am telemetry` output).
|
||||
pub fn status(app: &App) -> String {
|
||||
match &app.config.settings.telemetry {
|
||||
None => "telemetry: off (default) — set settings.telemetry.enabled: true to opt in"
|
||||
.to_string(),
|
||||
Some(t) if !t.enabled => "telemetry: off (settings.telemetry.enabled: false)".to_string(),
|
||||
Some(t) => {
|
||||
let path = counters_path(app);
|
||||
let c = load(&path, version());
|
||||
let endpoint = t
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or("(local only)");
|
||||
format!(
|
||||
"telemetry: on — endpoint {endpoint} · {} events pending · failures {}",
|
||||
c.total(),
|
||||
c.fail_count
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use crate::events::{Event, EventKind};
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(telemetry_yaml: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{telemetry_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
// Isolate the counters file (like dry_run_test): the default state
|
||||
// dir belongs to the real user.
|
||||
let mut p = app.paths.clone();
|
||||
p.state_file = dir.join("state.json");
|
||||
app.paths = p;
|
||||
app
|
||||
}
|
||||
|
||||
fn ev(kind: EventKind, code: Option<i32>) -> Event {
|
||||
let mut e = Event::now(kind);
|
||||
e.exit_code = code;
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_telemetry_records_nothing() {
|
||||
let app = test_app("");
|
||||
let path = counters_path(&app);
|
||||
maybe_record(&app, &ev(EventKind::Run, Some(0)));
|
||||
maybe_record(&app, &ev(EventKind::Install, None));
|
||||
maybe_flush(&app);
|
||||
assert!(!path.exists(), "nothing must be written while disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_records_aggregated_counters_only() {
|
||||
let app = test_app(" telemetry:\n enabled: true\n");
|
||||
maybe_record(&app, &ev(EventKind::Run, Some(0)));
|
||||
maybe_record(&app, &ev(EventKind::Run, Some(1)));
|
||||
maybe_record(&app, &ev(EventKind::Install, None));
|
||||
let path = counters_path(&app);
|
||||
let c = load(&path, version());
|
||||
assert_eq!(c.events.get("run"), Some(&2));
|
||||
assert_eq!(c.events.get("install"), Some(&1));
|
||||
assert_eq!(c.runs_succeeded, 1);
|
||||
assert_eq!(c.runs_failed, 1);
|
||||
assert_eq!(c.total(), 3);
|
||||
// The aggregated payload carries no identifiers.
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
assert!(!json.contains("claude"), "{json}");
|
||||
assert!(!json.contains("C:"), "no paths: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_without_endpoint_keeps_counters_local() {
|
||||
let app = test_app(" telemetry:\n enabled: true\n");
|
||||
for _ in 0..12 {
|
||||
maybe_record(&app, &ev(EventKind::Run, Some(0)));
|
||||
}
|
||||
maybe_flush(&app);
|
||||
let c = load(&counters_path(&app), version());
|
||||
assert_eq!(c.total(), 12, "no endpoint → counters stay local");
|
||||
assert_eq!(c.fail_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_sends_the_batch_and_resets() {
|
||||
// A local tiny_http server receives the batch (issue #76 test of the
|
||||
// batcher end to end without any external dependency). The server
|
||||
// runs on its own thread: it must answer while maybe_flush blocks.
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let app = test_app(&format!(
|
||||
" telemetry:\n enabled: true\n endpoint: http://127.0.0.1:{port}/v1/ping\n"
|
||||
));
|
||||
for _ in 0..BATCH_MIN_EVENTS {
|
||||
maybe_record(&app, &ev(EventKind::Run, Some(0)));
|
||||
}
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut req = server
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.expect("server recv failed")
|
||||
.expect("the batcher must POST the aggregated counters");
|
||||
let mut body = String::new();
|
||||
req.as_reader().read_to_string(&mut body).unwrap();
|
||||
let method = req.method().as_str().to_string();
|
||||
let url = req.url().to_string();
|
||||
let _ = req.respond(tiny_http::Response::from_string("ok"));
|
||||
(method, url, body)
|
||||
});
|
||||
maybe_flush(&app); // blocks until the server responds
|
||||
let (method, url, body) = handle.join().expect("server thread");
|
||||
assert_eq!(method, "POST");
|
||||
assert_eq!(url, "/v1/ping");
|
||||
let parsed: Counters = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(parsed.events.get("run"), Some(&BATCH_MIN_EVENTS));
|
||||
assert!(parsed.sent_at.is_none(), "sent_at is server-side state");
|
||||
// After a successful send, the counters are reset.
|
||||
let c = load(&counters_path(&app), version());
|
||||
assert_eq!(c.total(), 0);
|
||||
assert_eq!(c.fail_count, 0);
|
||||
assert!(c.sent_at.is_some(), "sent_at recorded after success");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_failure_increments_the_backoff_counter() {
|
||||
// 127.0.0.1:1 refuses connections immediately.
|
||||
let app = test_app(
|
||||
" telemetry:\n enabled: true\n endpoint: http://127.0.0.1:1/v1/ping\n",
|
||||
);
|
||||
for _ in 0..BATCH_MIN_EVENTS {
|
||||
maybe_record(&app, &ev(EventKind::Run, Some(0)));
|
||||
}
|
||||
maybe_flush(&app);
|
||||
let c = load(&counters_path(&app), version());
|
||||
assert_eq!(c.fail_count, 1);
|
||||
assert_eq!(c.total(), BATCH_MIN_EVENTS, "counters survive a failure");
|
||||
// Backoff: after MAX_CONSECUTIVE_FAILURES the batcher stops trying
|
||||
// until the age threshold passes again.
|
||||
for _ in 0..(MAX_CONSECUTIVE_FAILURES) {
|
||||
maybe_flush(&app);
|
||||
}
|
||||
let c = load(&counters_path(&app), version());
|
||||
assert_eq!(c.fail_count, MAX_CONSECUTIVE_FAILURES);
|
||||
maybe_flush(&app); // now suppressed
|
||||
let c = load(&counters_path(&app), version());
|
||||
assert_eq!(c.fail_count, MAX_CONSECUTIVE_FAILURES, "backoff holds");
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ const WEB: &str = r#"# Généré par 'am init --template web' — projet {projec
|
||||
# Répertoire : {dir}
|
||||
version: "1.0"
|
||||
groups:
|
||||
dev: [claude-code, opencode, gemini-cli]
|
||||
dev: [claude-code, opencode, antigravity-cli]
|
||||
profiles:
|
||||
dev:
|
||||
agent: claude-code
|
||||
|
||||
+4
-1
@@ -134,7 +134,9 @@ fn index_page(app: &App) -> HttpResult {
|
||||
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
|
||||
// Then every {{label}} token: replaced by the localized label (the
|
||||
// French text is both the key and the fallback, so nothing survives).
|
||||
while let Some(start) = html.find("{{") {
|
||||
let mut cursor = 0;
|
||||
while let Some(rel_start) = html[cursor..].find("{{") {
|
||||
let start = cursor + rel_start;
|
||||
let Some(rel_end) = html[start + 2..].find("}}") else {
|
||||
break;
|
||||
};
|
||||
@@ -142,6 +144,7 @@ fn index_page(app: &App) -> HttpResult {
|
||||
let key = html[start + 2..end].to_string();
|
||||
let translated = crate::i18n::tr_in(lang, &key);
|
||||
html.replace_range(start..end + 2, translated);
|
||||
cursor = start + translated.len();
|
||||
}
|
||||
HttpResult {
|
||||
status: 200,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -14,7 +14,7 @@ fn install_dry_run_creates_nothing() {
|
||||
&["install", "smelt", "--dry-run", "--yes"],
|
||||
);
|
||||
assert!(app.dry_run());
|
||||
let code = install_cmd::run(&app, "smelt", None, false).unwrap();
|
||||
let code = install_cmd::run(&app, "smelt", None, false, None, None, false).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// Nothing was written.
|
||||
assert!(!app.paths.install_dir.join("smelt").exists());
|
||||
|
||||
@@ -45,7 +45,7 @@ agents:
|
||||
#[test]
|
||||
fn run_emits_one_run_event_with_exit_code() {
|
||||
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
|
||||
let code = run_cmd::run(&app, "echo-agent", None, false, &[OsString::from("hello")]).unwrap();
|
||||
let code = run_cmd::run(&app, "echo-agent", None, None, false, false, &[OsString::from("hello")]).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let evs = events(&app);
|
||||
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
|
||||
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
|
||||
#[test]
|
||||
fn dry_run_writes_no_event() {
|
||||
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
|
||||
let code = run_cmd::run(&app, "echo-agent", None, false, &[]).unwrap();
|
||||
let code = run_cmd::run(&app, "echo-agent", None, None, false, false, &[]).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(events(&app).is_empty(), "dry-run must not append events");
|
||||
assert!(events::journal_files(&app.events_dir()).is_empty());
|
||||
|
||||
Reference in New Issue
Block a user