Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fa51cdb339 | ||
|
|
7f4d74f811 | ||
|
|
4c7866a25a | ||
|
|
aa0d8e43eb | ||
|
|
e82979e6a3 | ||
|
|
1b3fe09074 | ||
|
|
63dc36660b | ||
|
|
6a6676fc5d | ||
|
|
d9ffc34156 | ||
|
|
22bc529818 | ||
|
|
890ad953e5 | ||
|
|
98c0b30f68 |
@@ -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
|
||||
|
||||
+103
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
> Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux.
|
||||
>
|
||||
> 📦 Version : `0.4.3` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
|
||||
> 📦 Version : `0.6.0` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
|
||||
|
||||
---
|
||||
|
||||
@@ -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é.
|
||||
|
||||
@@ -28,12 +29,14 @@ flowchart TB
|
||||
CLI["Ligne de commande<br/>clap v4"]
|
||||
REPL["Shell interactif<br/>rustyline"]
|
||||
TUI["Dashboard TUI<br/>ratatui + crossterm"]
|
||||
WEB["Dashboard web<br/>tiny_http (127.0.0.1)"]
|
||||
end
|
||||
|
||||
subgraph CORE["⚙️ Noyau applicatif"]
|
||||
APP["App<br/>contexte partagé"]
|
||||
CLI_DEF["cli.rs<br/>définition des commandes"]
|
||||
CMD["commands/<br/>dispatch"]
|
||||
SHELL_AI["shell_ai.rs<br/>agent shell léger"]
|
||||
end
|
||||
|
||||
subgraph DATA["💾 Données persistantes"]
|
||||
@@ -61,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
|
||||
@@ -75,6 +80,7 @@ flowchart TB
|
||||
CMD --> DASH
|
||||
CMD --> STATS
|
||||
CMD --> SESSIONS
|
||||
SHELL_AI --> CMD
|
||||
```
|
||||
|
||||
---
|
||||
@@ -130,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
|
||||
@@ -146,15 +153,36 @@ src/
|
||||
├── help.rs ❓ Aide Nushell-style
|
||||
├── output.rs 🖨️ Logger et rendu
|
||||
├── tables.rs 📋 Rendu tabulaire
|
||||
├── theme.rs 🎨 Thèmes de couleur
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -432,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
|
||||
@@ -670,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
+213
-11
@@ -21,9 +21,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.4.3"
|
||||
version = "1.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -35,14 +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",
|
||||
@@ -139,6 +144,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
version = "0.6.1"
|
||||
@@ -296,6 +307,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -839,6 +856,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -1082,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",
|
||||
]
|
||||
|
||||
@@ -1119,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"
|
||||
@@ -1744,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"
|
||||
@@ -1868,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"
|
||||
@@ -2260,6 +2326,18 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -2270,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"
|
||||
@@ -2662,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]]
|
||||
@@ -2671,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]]
|
||||
@@ -2689,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]]
|
||||
@@ -2705,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"
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.4.3"
|
||||
version = "1.1.2"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
@@ -31,9 +31,14 @@ sha2 = "0.10"
|
||||
shell-words = "1"
|
||||
tar = "0.4"
|
||||
tempfile = "3"
|
||||
tiny_http = "0.12"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
toml = "0.8"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wait-timeout = "0.2"
|
||||
keyring = "3"
|
||||
keyring = { version = "3", features = ["windows-native", "linux-native"] }
|
||||
rpassword = "7"
|
||||
which = "7"
|
||||
zip = "0.6"
|
||||
crossterm = "0.29"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🚀 agent-manager — vos agents IA, gérés comme des apps
|
||||
|
||||
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
|
||||
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **72 agents connus**)
|
||||
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **77 agents connus**)
|
||||
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
|
||||
Go, Rust…) prise en charge pour vous.
|
||||
|
||||
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
|
||||
|
||||
## ✨ Pourquoi am ?
|
||||
|
||||
- 🗂️ **Catalogue de 72 agents prêts à l'emploi** — descriptions, méthodes
|
||||
- 🗂️ **Catalogue de 77 agents prêts à l'emploi** — descriptions, méthodes
|
||||
d'installation, dépendances et commandes de démarrage déjà configurées.
|
||||
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
|
||||
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
|
||||
@@ -48,6 +48,32 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
|
||||
> 🔄 **Mise à jour** : relancez simplement la même commande.
|
||||
|
||||
### 🚀 Premier lancement (onboarding v1.1.0)
|
||||
|
||||
Au premier lancement (ou dès que vous tapez `am ai` / `am ask` sans
|
||||
provider configuré), `am` vous propose le wizard d'onboarding :
|
||||
|
||||
am setup
|
||||
|
||||
Il vous guide en 3 étapes :
|
||||
1. **Provider** : anthropic, openai, deepseek, google, ollama (local) ou
|
||||
custom (base URL).
|
||||
2. **Token API** : saisie masquée, stockée dans le trousseau OS (keyring)
|
||||
— jamais écrite en clair dans la config.
|
||||
3. **Modèle par défaut** : choisi parmi les modèles déclarés du provider.
|
||||
|
||||
Le wizard installe ensuite **aichat** (le moteur de `am ai`) si vous
|
||||
acceptez, et génère **6 rôles copilot** pour aichat (`am setup --roles`
|
||||
pour les régénérer) :
|
||||
|
||||
| Rôle | Rôle |
|
||||
|---|---|
|
||||
| `am-copilot` — guide & catalogue am | `am-dev` — code, git, tests |
|
||||
| `am-operator` — shell sécurisé | `am-analyst` — coûts, logs, stats |
|
||||
| `am-do` — exécution pure pour `--exec` | `am-orchestrator` — groupes, lab |
|
||||
|
||||
Utilisation : `am ai --role am-operator "compresse les fichiers JSON"`.
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Vos 3 premières commandes
|
||||
@@ -77,8 +103,30 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
| am list | agents installés (gérés + détectés sur le PATH) |
|
||||
| am list --all | tout le catalogue, avec l'état de chacun |
|
||||
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
|
||||
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
|
||||
| am ask "<demande>" | langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (settings.ask), confirmation avant exécution — ex: am ask "installe claude et lance-le" |
|
||||
| am ai "<demande>" [--exec] [--files <path>] [--role <rôle>] | langage naturel → action shell via AIChat (alias: am shell) : conversationnel par défaut ; --exec génère la commande, la classe safe/risky et applique la politique de sécurité (settings.shell_ai, dry-run par défaut — --yes pour exécuter) ; --role = rôle copilot am-* (am setup) |
|
||||
| am setup [--roles] | wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat + rôles copilot (v1.1.0) |
|
||||
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
|
||||
| am status [agent] | état, version, PID, logs |
|
||||
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
|
||||
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
|
||||
| am providers list / add / set-token | registre LLM centralisé : base_url, modèles par provider, clé dans le trousseau (jamais en clair, résolue via @secret) |
|
||||
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
|
||||
| am registry publish/search/install | registre communautaire de catalogues (Gitea) : publication, recherche, installation avec checksum sha256 vérifié et décision de confiance explicite |
|
||||
| am audit | qui a modifié quoi, quand (checksums config + journal) |
|
||||
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
|
||||
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
|
||||
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
|
||||
| am doctor --watch <s> | vérifications périodiques de l'environnement + alerte en cas de panne |
|
||||
| am monitor [--json] | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils |
|
||||
| am stats --costs | coût estimé par agent (tokens in/out, $) — modèles de prix configurables |
|
||||
| am sync [--message <m>] | sauvegarde git de l'état (state.json, journal, historique) — secrets exclus |
|
||||
| am migrate export/import | bundle de transfert machine A → B (config + état + historique + backups) |
|
||||
| am service install <agent> --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto |
|
||||
| am schedule add <cmd...> --at HH:MM | planifie une commande am (ex: update --all) ; list / remove / run |
|
||||
| am start group:dev --parallel | orchestration de groupes : ordre, --parallel, attente de santé (healthcheck) |
|
||||
| am run <agent> --container | exécute l'agent dans son profil conteneur (docker/podman) |
|
||||
|
||||
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
|
||||
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
@@ -96,6 +144,48 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
|
||||
| am export / am import | sauvegarde et restaure config + état |
|
||||
|
||||
### 📁 Où et comment agent-manager installe et gère les agents
|
||||
|
||||
`am` ne touche jamais au système : chaque agent est installé dans un **répertoire utilisateur isolé**, et ses exécutables (ou shims) sont centralisés dans un `bin/` commun que `am` met automatiquement sur le `PATH` au lancement.
|
||||
|
||||
| Plateforme | Répertoire de l'agent | Dossier `bin` partagé | Fichier d'état | Configuration utilisateur |
|
||||
|---|---|---|---|---|
|
||||
| Linux | `~/.local/share/agent-manager/agents/<agent>/` | `~/.local/share/agent-manager/bin/` | `~/.local/state/agent-manager/state.json` | `~/.config/agent-manager/config.yaml` |
|
||||
| macOS | `~/Library/Application Support/agent-manager/agents/<agent>/` | `~/Library/Application Support/agent-manager/bin/` | `~/Library/Application Support/agent-manager/state.json` | `~/.config/agent-manager/config.yaml` |
|
||||
| Windows | `%LOCALAPPDATA%\agent-manager\agents\<agent>\` | `%LOCALAPPDATA%\agent-manager\bin\` | `%LOCALAPPDATA%\agent-manager\state.json` | `%APPDATA%\agent-manager\config.yaml` |
|
||||
|
||||
Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AGENT_MANAGER_CONFIG_DIR` peuvent déplacer ces emplacements. Le répertoire d'installation est aussi configurable via `settings.install_dir` et celui des logs via `settings.log_dir`.
|
||||
|
||||
#### Cycle d'installation (`am install <agent>`)
|
||||
|
||||
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (77 agents) avec `~/.config/agent-manager/config.yaml` et un éventuel `./agent-manager.yaml`. Vos définitions remplacent les entrées par défaut du même nom.
|
||||
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place.
|
||||
3. **Choix de la méthode** — chaque agent déclare une méthode principale et des alternatives. `--method <nom>` force le choix ; sinon `am` prend la première disponible. Pour `am update`, c'est la méthode utilisée lors de l'installation initiale qui est reconstruite.
|
||||
4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
|
||||
|
||||
| Méthode | Ce que `am` exécute localement | Où ça atterrit |
|
||||
|---|---|---|
|
||||
| `npm` | `npm install -g --prefix <root> <package>` | binaires dans `<root>/bin/`, liens ou copie dans `bin/` |
|
||||
| `bun` | `BUN_INSTALL=<root> bun install -g <package>` | binaires dans `<root>/bin/`, shims dans `bin/` |
|
||||
| `pip` | `python -m venv <root>/venv` puis `<venv>/bin/pip install --upgrade <package>` | exécutables dans `<root>/venv/bin/` |
|
||||
| `uv` | `uv venv --python 3.13 <root>/venv` puis `uv pip install --python <venv>/bin/python <package>` | exécutables dans `<root>/venv/bin/` |
|
||||
| `cargo` | `cargo install --root <root> <crate>` | binaires dans `<root>/bin/` |
|
||||
| `go` | `GOBIN=<bindir> go install <module>@latest` | binaire directement dans `bin/` |
|
||||
| `curl` | télécharge le script, l'affiche en `--verbose`, puis l'exécute avec `sh <script>` dans `<root>/` | le script gère son propre déploiement ; `am` repère les binaires résultants |
|
||||
| `binary` | télécharge un asset release (`url` ou `repo`), vérifie le `sha256` si fourni, extrait dans `<root>/dist/`, copie dans `bin/` | binaire unique dans `bin/` |
|
||||
| `git` | `git clone --depth 1 <repo> <root>/repo`, exécute les commandes `build`, puis copie `binary_path` dans `bin/` ou crée un shim | `bin/<agent>` ou shim |
|
||||
|
||||
5. **Post-installation** — `am` exécute les commandes `post_install` déclarées dans la fiche, avec `<bindir>` en tête du `PATH`.
|
||||
6. **Détection de version** — `am` lance `<binaire> --version` (puis `-V`) pour enregistrer la version réelle.
|
||||
7. **Enregistrement** — une entrée est écrite dans `state.json` (`name`, `version`, `method`, `install_dir`, `bins`, `run`, `installed_at`, `pid`, etc.).
|
||||
|
||||
#### Cycle de gestion (`am start / stop / update / uninstall`)
|
||||
|
||||
- **Démarrage** — `am start <agent>` lit `state.json` pour retrouver le binaire et la commande `run` configurée. En arrière-plan (`--background`), le PID est enregistré dans `state.json` et la sortie est redirigée vers `<log_dir>/<agent>.log`.
|
||||
- **Arrêt** — `am stop` envoie `SIGTERM`, attend `settings.stop_timeout_secs` (5 s par défaut), puis `SIGKILL` si besoin. Le PID est effacé de `state.json`.
|
||||
- **Mise à jour** — `am update <agent>` détermine la dernière version disponible (registre npm/PyPI/cargo/release), crée une sauvegarde (`am update --rollback`), réinstalle avec la même méthode et met à jour `state.json`. Les versions épinglées (`pin_version`) sont respectées.
|
||||
- **Désinstallation** — `am uninstall <agent>` arrête le processus, supprime `<root>/`, supprime les binaires de `bin/` et efface l'entrée de `state.json`. `--purge` efface aussi les logs et retire la définition du fichier de config utilisateur.
|
||||
|
||||
### 🚀 Exécuter
|
||||
|
||||
| Commande | Rôle |
|
||||
@@ -118,8 +208,12 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am projects [nom] | quel agent travaille sur quel projet (agent dominant, sessions, durées) |
|
||||
| am sessions [agent] --status --show --resume | registre des sessions + reprise d'une session arrêtée |
|
||||
| am history --search --failed --rerun N | historique des commandes (am + shell), filtrable et réexécutable |
|
||||
| am history 12..25 --save <f> · am playbook <f> | exporte une plage en playbook YAML et la rejoue pas à pas ({{var}}) |
|
||||
| am lab --agents a,b --task <f> [--parallel] [--json] | benchmark : même tâche sur plusieurs agents (durée, exit, coût) — tâches versionnables dans <state>/lab/ |
|
||||
| am plugins [--test <nom>] | scripts d'extension sur les événements (on_install/on_start/on_stop/on_update), contrat JSON stdin/stdout, timeout — exemples dans examples/plugins/ |
|
||||
| am logs <agent> --follow | tail du log d'un agent, en direct |
|
||||
| am dashboard | tableau de bord TUI temps réel : vue d'ensemble, activité, stats, sessions, projets (Tab/←/→ : onglets, j/k : défilement, q : quitter) |
|
||||
| am web [--port <p>] | dashboard web local en lecture seule (127.0.0.1) : vue d'ensemble, graphiques, sessions, événements, projets — API JSON /api/* avec les mêmes contrats que les sorties --json |
|
||||
|
||||
### 🎯 Personnaliser
|
||||
|
||||
@@ -144,6 +238,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am config show / edit / add / set | gère votre configuration (hooks on_start/on_stop/… dans settings.hooks) |
|
||||
| am open <agent> | ouvre le répertoire d'installation dans l'explorateur |
|
||||
| am completion <shell> [--installed] | script de complétion (bash, zsh, fish, powershell, elvish) ; --installed complète dynamiquement les agents installés, alias et groupes |
|
||||
| am serve --token [--port <p>] | API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask…) — rate limiting par IP, TLS via reverse proxy |
|
||||
| am self-update | met à jour am lui-même (si configuré) |
|
||||
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
|
||||
| am | shell interactif : bannière, passerelle système, Tab et historique |
|
||||
@@ -190,6 +285,7 @@ Disponibles avant **ou après** la sous-commande.
|
||||
| -v, --verbose | affiche chaque commande exécutée |
|
||||
| -q, --quiet | uniquement les erreurs |
|
||||
| --no-color | désactive les couleurs |
|
||||
| --lang <fr\|en> | langue des messages : fr (défaut) ou en — AM_LANG/LANG lus sinon |
|
||||
| -c, --config <fichier> | configuration alternative |
|
||||
|
||||
---
|
||||
@@ -202,6 +298,7 @@ Disponibles avant **ou après** la sous-commande.
|
||||
am start cc --profile dev # profil d'environnement : env + args injectés
|
||||
|
||||
am dashboard # TUI temps réel : toute l'activité d'un coup d'œil
|
||||
am web # dashboard web local (ouvre le navigateur) : http://127.0.0.1:7878/
|
||||
am favorite cc && am tag cc python # personnalisez votre parc
|
||||
am search --tag python # retrouvez les agents que vous avez tagués
|
||||
|
||||
@@ -209,6 +306,7 @@ Disponibles avant **ou après** la sous-commande.
|
||||
❯ inst<Tab> # complétion Tab : commandes, agents installés, groupes, options
|
||||
❯ install jcode
|
||||
❯ dashboard # le dashboard se lance aussi depuis le REPL
|
||||
❯ web # ... et le dashboard web aussi (am web --port pour changer le port)
|
||||
❯ exit # historique persistant (flèches haut/bas)
|
||||
|
||||
### 🐚 Passerelle système
|
||||
@@ -285,7 +383,7 @@ supprimez aussi ces emplacements.)
|
||||
1. --config <fichier>
|
||||
2. ./agent-manager.yaml (répertoire courant)
|
||||
3. ~/.config/agent-manager/config.yaml
|
||||
4. catalogue embarqué (72 agents)
|
||||
4. catalogue embarqué (77 agents)
|
||||
|
||||
Vos définitions **complètent ou surchargent** le catalogue par nom.
|
||||
|
||||
|
||||
+247
-47
@@ -1,6 +1,7 @@
|
||||
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
||||
|
||||
> **Version du document : 2.2** · Statut : propositions, non engagées
|
||||
> **Version du document : 3.0** · Statut : phases 0 à 3 livrées
|
||||
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.0
|
||||
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
||||
>
|
||||
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
||||
@@ -13,6 +14,74 @@
|
||||
> 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +103,7 @@
|
||||
|
||||
## 1. 📌 Résumé exécutif
|
||||
|
||||
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 72 agents au
|
||||
**am** est aujourd'hui un excellent *gestionnaire* d'agents : 77 agents au
|
||||
catalogue, 9 méthodes d'installation, dépendances résolues automatiquement,
|
||||
processus pilotés, REPL avec passerelle shell, sauvegarde export/import.
|
||||
|
||||
@@ -96,7 +165,7 @@ quelques jours, à caler avant ou pendant la construction du journal.*
|
||||
|
||||
| Brique actuelle | Fichier | Limite aujourd'hui |
|
||||
|---|---|---|
|
||||
| Catalogue 72 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
|
||||
| Catalogue 77 agents, alias, groupes | src/catalog.rs, config.yaml | recherche = sous-chaîne stricte, pas de ranking |
|
||||
| Installation (9 méthodes), dépendances OS | src/installers/, src/deps.rs, src/toolchain.rs | solide — rien à redire |
|
||||
| État local | src/state.rs (state.json v1) | installations + PID courant uniquement, **aucun historique** |
|
||||
| Détection externe + cache | src/probe.rs | excellent pattern de cache — à généraliser |
|
||||
@@ -169,8 +238,8 @@ taux de succès** — et **combien ça coûte**.
|
||||
| am top — classement des agents par utilisation | S | P1 |
|
||||
| am log — filtres agent/type/date, --follow | S → M | P0 → P1 |
|
||||
| am report — rapport hebdo/mensuel en markdown (top agents, échecs, changements) | M | P1 |
|
||||
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) | M | P2 |
|
||||
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes | L | P2 |
|
||||
| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) ✅ #49 | M | P2 |
|
||||
| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes ✅ #50 | L | P2 |
|
||||
| Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
|
||||
|
||||
am stats # vue globale
|
||||
@@ -188,7 +257,7 @@ taux de succès** — et **combien ça coûte**.
|
||||
| am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 |
|
||||
| Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 |
|
||||
| am history --rerun 42 (réexécution confirmée) | S | P1 |
|
||||
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml | L | P2 |
|
||||
| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml ✅ #51 | L | P2 |
|
||||
|
||||
am history --search "install" --failed
|
||||
am history --rerun 42
|
||||
@@ -245,11 +314,11 @@ consultable, reprenable, archivable.
|
||||
|---|---|---|
|
||||
| Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 |
|
||||
| am watch claude-code --restart-on-crash --notify | M | P1 |
|
||||
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart | L | P2 |
|
||||
| Planification : am schedule --daily update --all + check santé (cron / Task Scheduler) | M | P2 |
|
||||
| Orchestration de groupes : ordre, --parallel, attente de santé | L | P2 |
|
||||
| Conteneurs : profil docker/podman par agent (isolation à la demande) | L | P2 |
|
||||
| am doctor --watch — vérifications périodiques avec alertes | M | P2 |
|
||||
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart ✅ #55 | L | P2 |
|
||||
| Planification : am schedule add update --all --at 06:30 + check santé (cron / Task Scheduler) ✅ #56 | M | P2 |
|
||||
| Orchestration de groupes : ordre, --parallel, attente de santé ✅ #57 | L | P2 |
|
||||
| Conteneurs : profil docker/podman par agent (isolation à la demande) ✅ #58 | L | P2 |
|
||||
| am doctor --watch — vérifications périodiques avec alertes ✅ #59 | M | P2 |
|
||||
|
||||
### Axe 7 — 🧠 Intelligence & catalogue
|
||||
|
||||
@@ -284,9 +353,9 @@ consultable, reprenable, archivable.
|
||||
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL | L | P2 |
|
||||
| Synchronisation git automatique : settings.sync_repo, am sync, push à la fermeture du REPL ✅ #66 | L | P2 |
|
||||
| Partage de catalogue d'équipe : include par URL | S | P2 |
|
||||
| am migrate — assistant de transfert machine A → B | M | P2 |
|
||||
| am migrate — assistant de transfert machine A → B ✅ #68 | M | P2 |
|
||||
| am serve --token — API HTTP + WebSocket pour piloter à distance | XL | P3 |
|
||||
|
||||
### Axe 10 — 🧩 Confort & personnalisation
|
||||
@@ -328,6 +397,110 @@ consultable, reprenable, archivable.
|
||||
|
||||
---
|
||||
|
||||
### Axe 13 — ⚡ Shell AI & Action ⭐
|
||||
|
||||
🎯 Transformer une commande en langage naturel en une action shell exécutée par un agent léger, rapide et contextuel, sans démarrer un gros coding agent.
|
||||
|
||||
**Contexte :** `am` gère aujourd'hui ~77 agents IA, principalement des *coding agents*, des assistants et des outils SaaS. Les shell assistants sont maintenant au catalogue (AIChat, ShellGPT, Fabric, Shell AI, AI CLI — v1.0.4) : légers, rapides, conçus pour transformer une phrase en commande ou action sur le système de fichiers local (ex. *"traite les fichiers JSON du dossier courant pour extraire les clés uniques"*).
|
||||
|
||||
---
|
||||
|
||||
#### 13.1 Agents shell AI découverts (✅ tous ajoutés au catalogue — v1.0.4, issues #94 #95)
|
||||
|
||||
| Outil | Repo | Langage | Force | Maturité |
|
||||
|---|---|---|---|---|
|
||||
| ✅ **AIChat** | `sigoden/aichat` | Rust | Shell assistant natif, RAG, agents, fichiers/répertoires, 20+ providers | **10.4k stars**, très actif |
|
||||
| ✅ **ShellGPT** | `TheR1D/shell_gpt` | Python | Génère/exécute commandes shell, code, docs | Très connu, mature |
|
||||
| ✅ **Fabric** | `danielmiessler/fabric` | Go | Patterns AI (summarize, extract wisdom), CLI | Très populaire, orienté contenu/texte |
|
||||
| ✅ **Shell AI** | `nishant9083/shell-ai` | TypeScript | Agent ReAct local via Ollama, MCP, filesystem tools | Plus récent, prometteur mais jeune |
|
||||
| ✅ **AI CLI** | `kriserickson/ai-cli` | Go | Natural language → commandes shell avec safety policy (`risk` / `certainty`) | Petit, simple, moins connu |
|
||||
|
||||
---
|
||||
|
||||
#### 13.2 Recommandation : AIChat (`sigoden/aichat`)
|
||||
|
||||
**Choix privilégié pour la fonction Shell AI.**
|
||||
|
||||
| Avantage | Détails |
|
||||
|---|---|
|
||||
| **Léger & rapide** | Rust, binaire unique, cold start rapide, idéal pour RPi 4 |
|
||||
| **Shell Assistant natif** | Génère et exécute des commandes shell à partir du langage naturel |
|
||||
| **Multi-provider** | OpenAI, Claude, Gemini, DeepSeek, Groq, Ollama, OpenRouter… |
|
||||
| **Passage de contexte** | `aichat -f .` injecte le dossier ou les fichiers dans le prompt |
|
||||
| **Mode exécution** | `aichat -e "..."` exécute directement, mode conversationnel sinon |
|
||||
| **Facilité d'intégration** | Release binaire GitHub, installable via `binary` dans le catalogue |
|
||||
|
||||
Exemples d'usage ciblés :
|
||||
|
||||
```bash
|
||||
# Lister et traiter les fichiers JSON du dossier courant
|
||||
aichat -f . -e "liste tous les fichiers JSON et extrait les clés uniques"
|
||||
|
||||
# Résumer un dossier de fichiers
|
||||
aichat -f . "résume les données de ces fichiers JSON"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 13.3 Fonctionnalités prévues
|
||||
|
||||
| Fonctionnalité | Description | Effort | Phase |
|
||||
|---|---|---|---|
|
||||
| ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 |
|
||||
| ✅ **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| ✅ **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 |
|
||||
| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 |
|
||||
| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 |
|
||||
| **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 |
|
||||
|
||||
---
|
||||
|
||||
#### 13.4 Risques & garde-fous
|
||||
|
||||
| Risque | Mitigation |
|
||||
|---|---|
|
||||
| Exécution automatique de commandes dangereuses | `--dry-run` par défaut, confirmation obligatoire avant toute commande `risky` |
|
||||
| Coût API récurrent | Modèle cheap par défaut (`gpt-4.1-mini`, `gemini-flash`) ou Ollama local |
|
||||
| Fuite de données sensibles | Support du mode local Ollama, pas d'envoi hors du provider configuré |
|
||||
| Installation lente sur RPi | Utiliser l'installateur `binary` GitHub Releases plutôt que `cargo` |
|
||||
|
||||
---
|
||||
|
||||
#### 13.5 Idées sœurs (à intégrer ou lier à d'autres axes)
|
||||
|
||||
| Commande | Description | Axe lié |
|
||||
|---|---|---|
|
||||
| `am summarize <fichier/dossier>` | Résume un fichier ou un dossier via AIChat/Fabric | Axe 13 |
|
||||
| `am explain <commande>` | Explique une commande shell avant exécution | Axe 13 |
|
||||
| `am commit` | Génère un message de commit depuis `git diff` | Axe 13 / Axe 3 |
|
||||
| `am review` | Review rapide d'un diff ou d'une PR | Axe 7 |
|
||||
| `am translate <fichier> --to en` | Traduit un fichier markdown/doc | Axe 13 |
|
||||
| `am ask-code "..."` | Pose une question sur le codebase sans lancer un gros agent | Axe 7 |
|
||||
| `am doc <dossier>` | Génère un README/doc à partir du code | Axe 13 / Axe 7 |
|
||||
| `am fix` | Corrige une commande shell qui a échoué | Axe 13 |
|
||||
| `am note` | Extrait des action items d'un texte/réunion | Axe 10 |
|
||||
| `am search-web "..."` | Recherche web rapide et réponse synthétisée | Axe 7 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 🚀 Onboarding & Copilot aichat (épique v1.1.0)
|
||||
|
||||
> Objectif : réduire le **Time-To-First-Value à 0** — rendre `am` opérationnel
|
||||
> pour l'IA locale dès la première seconde, et faire d'aichat le copilot de
|
||||
> agent-manager. Livré en **v1.1.0 (2026-08-20)**.
|
||||
|
||||
| Fonctionnalité | Description | Statut |
|
||||
|---|---|---|
|
||||
| ✅ **Wizard d'onboarding (`am setup`)** | Provider (anthropic, openai, deepseek, google, ollama, custom), token masqué → trousseau OS (keyring, jamais en clair), modèle par défaut, ping API non-bloquant. Déclenché par `am ai`/`am ask`/banner REPL quand aucun provider n'est configuré (message discret, jamais bloquant), ré-exécutable à la main (mode revoir) | ✅ v1.1.0 |
|
||||
| ✅ **Installation automatique d'aichat** | Étape du wizard « Installer le moteur IA (aichat) ? [Y/n] » → `am install aichat` (catalogue) ; `am ai` sans aichat propose l'installation ; install.ps1/install.sh enchaînent sur `am setup` si stdin interactif | ✅ v1.1.0 |
|
||||
| ✅ **Rôles copilot aichat (am-*)** | 6 agents générés dans `~/.config/aichat/agents/` (ou %APPDATA%\\aichat\\agents) : am-copilot (guide & catalogue), am-operator (shell sécurisé), am-dev (code & git), am-do (exécution pure pour --exec), am-analyst (coûts/logs/stats), am-orchestrator (groupes/lab). Prompts bilingues (FR par défaut), privilégient les contrats `--json` | ✅ v1.1.0 |
|
||||
| ✅ **`am ai --role <nom>`** | Rôle copilot passé à aichat (`--agent`) ; validation du fichier généré ; complétion REPL des rôles am-* ; config aichat créée (provider + modèle) seulement si absente | ✅ v1.1.0 |
|
||||
| ✅ **Settings dédiés** | `settings.shell_ai` (default_safety dry-run/confirm/auto, risky_patterns) documenté dans config.yaml ; module `src/setup.rs` + `src/roles.rs` | ✅ v1.1.0 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 🚀 Jalons versionnés
|
||||
|
||||
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|
||||
@@ -430,33 +603,33 @@ moins de 2 secondes.
|
||||
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M |
|
||||
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) | L |
|
||||
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L |
|
||||
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | Templates — am init --template web | M |
|
||||
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | am sessions export + rétention configurable | M |
|
||||
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
|
||||
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
|
||||
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
|
||||
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L |
|
||||
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L |
|
||||
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M |
|
||||
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | Catalogue distant — am catalog update / am catalog add <url> | M |
|
||||
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | am suggest — « un agent pour du Python » (tags + usage réel) | M |
|
||||
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
|
||||
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
|
||||
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
|
||||
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | Politiques — pin de version, settings.update_policy | S |
|
||||
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
|
||||
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | Partage de catalogue d'équipe (include par URL) | S |
|
||||
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M |
|
||||
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | Thèmes couleurs du REPL | S |
|
||||
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
|
||||
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | Lien agent ↔ modèle — am run --model | M |
|
||||
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | doctor vérifie ollama / llama-server comme n'importe quel outil | S |
|
||||
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | am models prune — purge des modèles inutilisés | S |
|
||||
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L |
|
||||
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | Plugin scripts (hooks avancés, intégration CI) | M |
|
||||
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session (stats --costs, cost_models) ✅ | M |
|
||||
| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) ✅ | L |
|
||||
| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | ✅ Playbooks — am history --save + am playbook | L |
|
||||
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M |
|
||||
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M |
|
||||
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | ✅ am web — serveur local + API JSON + graphiques (dashboard embarqué, contrats --json, 127.0.0.1) | XL |
|
||||
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) ✅ | L |
|
||||
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (add --at / list / remove / run) ✅ | M |
|
||||
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) ✅ | L |
|
||||
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent ✅ | L |
|
||||
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes ✅ | M |
|
||||
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
|
||||
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
|
||||
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | ✅ am lab — benchmark : même tâche sur N agents | L |
|
||||
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | ✅ am audit — qui a modifié quoi quand | M |
|
||||
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | ✅ am update --rollback — backup automatique avant mise à jour | M |
|
||||
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S |
|
||||
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | ✅ Synchronisation git automatique — am sync (sync_repo, sync_on_exit, secrets exclus) | L |
|
||||
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S |
|
||||
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | ✅ am migrate — assistant de transfert machine A → B (bundle .amx + doctor) | M |
|
||||
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | ✅ Thèmes couleurs du REPL | S |
|
||||
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | ✅ am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
|
||||
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | ✅ Lien agent ↔ modèle — am run --model | M |
|
||||
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | ✅ doctor vérifie ollama / llama-server comme n'importe quel outil | S |
|
||||
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | ✅ am models prune — purge des modèles inutilisés | S |
|
||||
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | ✅ i18n — messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt) | L |
|
||||
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | ✅ Plugin scripts (hooks avancés, intégration CI) | M |
|
||||
|
||||
Critère de sortie du jalon : parc auto-supervisé (crash = redémarrage +
|
||||
alerte).
|
||||
@@ -467,16 +640,43 @@ alerte).
|
||||
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
|
||||
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
|
||||
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
|
||||
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | ✅ Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
|
||||
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | ✅ Profils sandbox par agent (commandes/répertoires autorisés) | L |
|
||||
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | ✅ am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
|
||||
|
||||
Critère de sortie du jalon : dashboard web complet + API distante.
|
||||
|
||||
### Phase v0.7.0 — Providers & configuration automatisée (P0, PRIORITAIRE)
|
||||
|
||||
Épique : [issue #87](https://git.dracodev.net/Projets/agent-manager/issues/87)
|
||||
· milestone v0.7.0 · 5 issues · **priorité P0 : livrée AVANT la Phase 3
|
||||
(v1.0, #76–#80 en P3), sauf dépendances**.
|
||||
|
||||
Centralise tokens/providers/modèles dans UN registre (settings.providers +
|
||||
keyring partagé) et configure automatiquement les agents à l'installation :
|
||||
`am install <agent>` branche le provider/modèle par défaut,
|
||||
`--provider/--model` pour surcharger, `--no-config` pour le comportement
|
||||
actuel, `am run --provider` pour surcharger au lancement (cloud inclus).
|
||||
S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés.
|
||||
|
||||
| # | Issue | Effort | Dépend de |
|
||||
|---|---|---|---|
|
||||
| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — |
|
||||
| [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | ✅ Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 |
|
||||
| [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | ✅ AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 |
|
||||
| [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | ✅ Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 |
|
||||
| [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | ✅ `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 |
|
||||
|
||||
Critère de sortie du jalon : un agent installé est opérationnel sans
|
||||
configuration manuelle (tokens au keyring, provider/modèle par défaut).
|
||||
|
||||
---
|
||||
|
||||
*Document généré à partir de l'analyse du code (v0.2.7, commit d886de3).
|
||||
La phase 0 est livrée (v0.3.0) ; les découpages des phases 1 (v0.4.0),
|
||||
2 (v0.5.0) et 3 (v1.0) sont en place sur Gitea (issues #25 à #80).*
|
||||
Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers,
|
||||
2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2
|
||||
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
|
||||
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
|
||||
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe).*
|
||||
|
||||
+191
@@ -20,6 +20,104 @@ settings:
|
||||
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
|
||||
self_update_repo: Projets/agent-manager # repo used by am self-update (release assets)
|
||||
self_update_base_url: https://git.dracodev.net/api/v1 # serveur de release (Gitea ici; GitHub: https://api.github.com)
|
||||
models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73)
|
||||
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
|
||||
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
|
||||
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
|
||||
sessions_retention_days: 90 # purge des sessions terminées au-delà de N jours (#53)
|
||||
# Prix des modèles (USD par million de tokens) pour am stats --costs (#49).
|
||||
# cost_models: # décommenter pour surcharger
|
||||
# claude-sonnet-4: { input_per_mtok: 3.0, output_per_mtok: 15.0 }
|
||||
# deepseek-chat: { input_per_mtok: 0.27, output_per_mtok: 1.10 }
|
||||
# Seuils d'alerte de am monitor (#50) : 0 désactive le seuil.
|
||||
# monitor_thresholds:
|
||||
# cpu_pct: 80.0
|
||||
# mem_mb: 2048
|
||||
# Plugins d'événements (#75) : scripts exécutés sur on_install/on_start/
|
||||
# on_stop/on_update, contrat JSON stdin/stdout, timeout, échec non bloquant.
|
||||
# Le répertoire <state>/plugins/ contient les scripts (+ manifeste .yaml).
|
||||
# plugins:
|
||||
# timeout_secs: 10 # temps max d'un plugin (défaut 10 s)
|
||||
# enabled: [notify] # vide = tous les plugins du répertoire
|
||||
# Sauvegarde git de l'état (#66) : dépôt où am sync pousse state.json,
|
||||
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
|
||||
# sync_repo: https://git.dracodev.net/bruno/am-state.git
|
||||
# sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in)
|
||||
# Télémétrie anonyme opt-in (#76) : compteurs agrégés uniquement (jamais de
|
||||
# chemins, commandes ni identifiants). Désactivée par défaut — RIEN n'est
|
||||
# collecté ni envoyé tant que enabled n'est pas explicitement true.
|
||||
# telemetry:
|
||||
# enabled: false
|
||||
# endpoint: https://exemple.tld/v1/ping # optionnel : envoi batch périodique
|
||||
# am ask (#78) : langage naturel → commande am. Les règles locales marchent
|
||||
# toujours, hors-ligne, sans dépendance. Le raffinement LLM optionnel passe
|
||||
# par le registre providers (base_url + token du keyring + modèle) — seul
|
||||
# le texte de la requête est envoyé, jamais les données du journal.
|
||||
# ask:
|
||||
# enabled: true # false désactive entièrement am ask
|
||||
# provider: deepseek # optionnel (défaut: settings.default_provider)
|
||||
# model: deepseek-chat # optionnel (défaut: modèle par défaut du provider)
|
||||
# Registre communautaire (#77) : sources de catalogues de confiance +
|
||||
# auteur par défaut pour am registry publish. L'installation d'une source
|
||||
# inconnue exige une confirmation (checksum sha256 vérifié avant tout).
|
||||
# registry:
|
||||
# sources:
|
||||
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
|
||||
# author: bruno
|
||||
# am ai (#96 #97) : politique de sécurité Shell AI. dry-run par défaut —
|
||||
# aucune commande modifiante n'est exécutée sans confirmation explicite
|
||||
# (--yes). `confirm` demande validation pour les commandes risky ;
|
||||
# `auto` exécute tout (déconseillé). Les motifs supplémentaires s'ajoutent
|
||||
# aux patterns intégrés (rm -rf, dd if, mkfs, chmod -R 777, ...).
|
||||
# shell_ai:
|
||||
# default_safety: dry-run # dry-run | confirm | auto
|
||||
# risky_patterns:
|
||||
# - "rm -rf"
|
||||
# - "> /dev"
|
||||
# - "dd if"
|
||||
# Profil sandbox par agent (#79) : commandes autorisées (binaires), répertoires
|
||||
# de travail et politique réseau. Désactivé par défaut (mode non sandboxé).
|
||||
# Les tentatives refusées sont journalisées (events sandbox) pour l'audit.
|
||||
# Exemple dans un agent :
|
||||
# sandbox:
|
||||
# enabled: true
|
||||
# commands: [python, git]
|
||||
# dirs: ["~/workspace"]
|
||||
# network: false
|
||||
# Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par
|
||||
# défaut. Le provider par défaut est utilisé à l'install/au run quand aucun
|
||||
# n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans
|
||||
# le keyring OS sous providers/<nom>/api_key (issue #89, am providers set-token).
|
||||
# Pour retirer un provider du catalogue par défaut : am providers remove <nom>
|
||||
# (écrit "<nom>: null" dans votre config — le null supprime l'entrée au merge).
|
||||
default_provider: anthropic
|
||||
providers:
|
||||
anthropic:
|
||||
base_url: https://api.anthropic.com/v1
|
||||
default_model: claude-sonnet-4-5
|
||||
models: [claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5]
|
||||
openai:
|
||||
base_url: https://api.openai.com/v1
|
||||
default_model: gpt-5.2
|
||||
models: [gpt-5.2, gpt-5.1-mini, o4-mini]
|
||||
deepseek:
|
||||
base_url: https://api.deepseek.com
|
||||
default_model: deepseek-chat
|
||||
models: [deepseek-chat, deepseek-reasoner]
|
||||
google:
|
||||
base_url: https://generativelanguage.googleapis.com/v1beta
|
||||
default_model: gemini-2.5-pro
|
||||
models: [gemini-2.5-pro, gemini-2.5-flash]
|
||||
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:
|
||||
@@ -31,6 +129,7 @@ aliases:
|
||||
gjc: gajae-code
|
||||
tiny: tiny-agents
|
||||
continue: continue-cli
|
||||
ai: aichat
|
||||
|
||||
# --- Groups (start/stop/restart several agents together) ----------------------
|
||||
groups:
|
||||
@@ -55,6 +154,12 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: claude
|
||||
provider: anthropic
|
||||
config:
|
||||
env_map:
|
||||
api_key: ANTHROPIC_API_KEY
|
||||
model: ANTHROPIC_MODEL
|
||||
base_url: ANTHROPIC_BASE_URL
|
||||
tags: [anthropic, assistant, source-available, mainstream]
|
||||
|
||||
- name: deepseek-harness
|
||||
@@ -68,6 +173,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
|
||||
@@ -208,6 +314,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
|
||||
@@ -458,6 +569,15 @@ agents:
|
||||
repo: 1ay1/agentty
|
||||
binary: agentty
|
||||
run: agentty
|
||||
config:
|
||||
provider_default: openai
|
||||
env_map:
|
||||
api_key: AGENTTY_API_KEY
|
||||
model: AGENTTY_MODEL
|
||||
base_url: AGENTTY_BASE_URL
|
||||
files:
|
||||
- path: ~/.config/agentty/config.toml
|
||||
keys: [model, base_url]
|
||||
tags: [c++, static, sandbox]
|
||||
|
||||
- name: nullclaw
|
||||
@@ -700,6 +820,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: "npx lazycodex-ai"
|
||||
# Sonde PATH explicite : le premier mot de `run` est npx (interpréteur) —
|
||||
# sans `detect`, npx sur le PATH suffirait à déclarer l'agent « external ».
|
||||
detect: lazycodex-ai
|
||||
note: "s'intègre dans Codex (nécessite @openai/codex)"
|
||||
tags: [harness, codex, plan]
|
||||
|
||||
@@ -772,6 +895,71 @@ agents:
|
||||
note: "étapes de build spécifiques au repo — consultez son README"
|
||||
tags: [simulator, orchestration]
|
||||
|
||||
# ---------------------------------------------------------------- Shell AI
|
||||
- name: aichat
|
||||
display_name: "AIChat"
|
||||
description: "All-in-one LLM CLI avec Shell Assistant, Chat-REPL, RAG, AI Tools & Agents et support fichiers/répertoires (-f). Idéal pour exécuter des micro-tâches shell via langage naturel (issue #94)."
|
||||
category: shell-ai
|
||||
website: https://github.com/sigoden/aichat
|
||||
install:
|
||||
type: binary
|
||||
repo: sigoden/aichat
|
||||
binary: aichat
|
||||
run: aichat
|
||||
tags: [shell, rust, multi-provider, rag, local, fast]
|
||||
|
||||
- name: ai-cli
|
||||
display_name: "AI CLI"
|
||||
description: "Traduit le langage naturel en commandes shell (OpenAI, OpenRouter ou serveur local) avec politique de sécurité (risk / certainty) avant exécution (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/kriserickson/ai-cli
|
||||
install:
|
||||
type: binary
|
||||
repo: kriserickson/ai-cli
|
||||
binary: ai
|
||||
run: ai
|
||||
tags: [shell, go, safety, multi-provider]
|
||||
|
||||
- name: fabric
|
||||
display_name: "Fabric"
|
||||
description: "Framework open source pour augmenter les humains avec l'IA : patterns réutilisables (summarize, extract wisdom, analyse de contenu), CLI Go 43k+ stars (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/danielmiessler/fabric
|
||||
install:
|
||||
type: go
|
||||
package: github.com/danielmiessler/fabric/cmd/fabric@latest
|
||||
dependencies:
|
||||
- { name: go, min_version: "1.22.0", install_hint: "https://go.dev/dl" }
|
||||
run: fabric
|
||||
tags: [patterns, go, content]
|
||||
|
||||
- name: shell-ai
|
||||
display_name: "Shell AI"
|
||||
description: "Agent ReAct local-first propulsé par Ollama : zéro réseau, outils filesystem, exécution de commandes shell et intégration MCP (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/nishant9083/shell-ai
|
||||
install:
|
||||
type: npm
|
||||
package: "@shell-ai/cli"
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: shell-ai
|
||||
note: "nécessite Ollama et un modèle (ollama pull gpt-oss) — agent jeune (15 stars)"
|
||||
tags: [shell, ollama, local, mcp, typescript]
|
||||
|
||||
- name: shellgpt
|
||||
display_name: "ShellGPT"
|
||||
description: "Outil de productivité CLI propulsé par LLM (GPT-5…) : génère et exécute des commandes shell, écrit du code et des docs, 12k+ stars (issue #95)."
|
||||
category: shell-ai
|
||||
website: https://github.com/TheR1D/shell_gpt
|
||||
install:
|
||||
type: pip
|
||||
package: shell-gpt
|
||||
dependencies:
|
||||
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
|
||||
run: sgpt
|
||||
tags: [shell, python, multi-provider]
|
||||
|
||||
# ----------------------------------------------------- Écosystème OpenClaw
|
||||
- name: openclaw
|
||||
display_name: "OpenClaw"
|
||||
@@ -797,6 +985,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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Tâche de benchmark (issue #62) : les mêmes arguments sont passés à la
|
||||
# commande de chaque agent. Copier dans <state_dir>/lab/ ou passer un
|
||||
# chemin explicite :
|
||||
# am lab --agents claude-code,aider --task hello.yaml --json
|
||||
name: hello
|
||||
description: "Répondre à la question la plus simple (sanity check du lab)."
|
||||
args: ["--prompt", "Réponds uniquement 'coucou' et rien d'autre."]
|
||||
# timeout: 300 # secondes par agent (défaut 600) — --timeout l'emporte
|
||||
@@ -0,0 +1,31 @@
|
||||
# Plugin scripts — agent-manager (issue #75)
|
||||
#
|
||||
# Les plugins sont des points d'extension par script, exécutés sur les
|
||||
# événements du cycle de vie des agents (complément des hooks de l'axe 6) :
|
||||
# on_install · on_start · on_stop · on_update
|
||||
#
|
||||
# Contrat :
|
||||
# - entrée : l'événement JSON sur stdin
|
||||
# {"event":"on_start","kind":"start","ts":"…","agent":"claude-code",
|
||||
# "pid":1234,"cwd":"…","project":"…","args":[…],"env_keys":[…],…}
|
||||
# - sortie : un objet JSON sur stdout
|
||||
# {"ok": true, "message": "…", "data": {…}}
|
||||
# ou {"ok": false, "error": "…"} — signalé en warning, jamais fatal
|
||||
# - une sortie invalide (pas de JSON) est signalée en warning sans casser am
|
||||
# - un plugin qui dépasse son timeout est tué (défaut 10 s)
|
||||
# - les plugins n'ont pas accès aux valeurs des secrets (env_keys seulement)
|
||||
# - AM_PLUGINS_RUNNING=1 est exporté pendant un plugin : un am imbriqué
|
||||
# saute les plugins (anti-récursion)
|
||||
#
|
||||
# Installation :
|
||||
# cp notify.py notify.yaml ~/.local/state/agent-manager/plugins/ (Linux)
|
||||
# cp notify.py notify.yaml %LOCALAPPDATA%\agent-manager\plugins\ (Windows)
|
||||
#
|
||||
# Vérification :
|
||||
# am plugins # liste les plugins découverts
|
||||
# am plugins --test notify # exécute le contrat sur un événement de test
|
||||
#
|
||||
# Configuration (config.yaml) :
|
||||
# plugins:
|
||||
# timeout_secs: 10 # temps max d'un plugin (défaut 10 s)
|
||||
# enabled: [notify] # vide = tous les plugins du répertoire
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plugin exemple (issue #75) : poste les événements install/update vers un
|
||||
webhook CI. Si la variable d'environnement CI_WEBHOOK_URL n'est pas définie,
|
||||
le plugin répond ok sans rien faire (échec non bloquant, jamais fatal).
|
||||
|
||||
Exemple : CI_WEBHOOK_URL=https://hooks.example.com/am-ci am update --all
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ev = json.load(sys.stdin)
|
||||
url = os.environ.get("CI_WEBHOOK_URL")
|
||||
if not url:
|
||||
print(json.dumps({"ok": True, "message": "CI_WEBHOOK_URL non définie — rien à faire"}))
|
||||
return 0
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(ev).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
status = resp.status
|
||||
except Exception as exc: # échec non bloquant : warning seulement
|
||||
print(json.dumps({"ok": False, "error": f"webhook: {exc}"}))
|
||||
return 0
|
||||
print(json.dumps({"ok": True, "message": f"webhook {status}"}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
# Manifest du plugin ci-webhook (issue #75).
|
||||
command: "python ci-webhook.py" # Linux : "python3 ci-webhook.py"
|
||||
events: [on_install, on_update]
|
||||
timeout_secs: 10
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plugin exemple (issue #75) : notifie le démarrage / l'arrêt d'un agent.
|
||||
|
||||
Contrat : l'événement JSON arrive sur stdin, la réponse JSON part sur stdout.
|
||||
Installation : copier notify.py + notify.yaml dans <state_dir>/plugins/ puis
|
||||
relancer am. Test : am plugins --test notify
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ev = json.load(sys.stdin)
|
||||
agent = ev.get("agent") or "inconnu"
|
||||
event = ev.get("event", "?")
|
||||
pid = ev.get("pid")
|
||||
detail = f" (pid {pid})" if pid else ""
|
||||
print(json.dumps({"ok": True, "message": f"{event} de {agent}{detail}"}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
# Manifest du plugin notify (issue #75).
|
||||
# command: interpréteur + script (indispensable sur Windows pour .py).
|
||||
command: "python notify.py" # Linux : "python3 notify.py"
|
||||
# Événements écoutés (défaut : les 4 événements du cycle de vie).
|
||||
events: [on_start, on_stop]
|
||||
# Temps maximal d'exécution avant kill (défaut : settings.plugins.timeout_secs, 10).
|
||||
timeout_secs: 5
|
||||
+11
-1
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-audit 1 "audit "
|
||||
.SH NAME
|
||||
audit \- Audit the configuration: who changed what, when (issue #63)
|
||||
.SH SYNOPSIS
|
||||
\fBaudit\fR [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Audit the configuration: who changed what, when (issue #63)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -0,0 +1,26 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-catalog 1 "catalog "
|
||||
.SH NAME
|
||||
catalog \- Manage remote catalogs: update the official one, add external ones
|
||||
.SH SYNOPSIS
|
||||
\fBcatalog\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
|
||||
.SH DESCRIPTION
|
||||
Manage remote catalogs: update the official one, add external ones
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
catalog\-update(1)
|
||||
Fetch the official catalog, show the diff and register it as an include
|
||||
.TP
|
||||
catalog\-add(1)
|
||||
Register an external catalog (same YAML format) by URL
|
||||
.TP
|
||||
catalog\-list(1)
|
||||
List the registered remote catalogs
|
||||
.TP
|
||||
catalog\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
doctor \- Check the environment: tools, config validity, paths, permissions
|
||||
.SH SYNOPSIS
|
||||
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-\-watch\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Check the environment: tools, config validity, paths, permissions
|
||||
.SH OPTIONS
|
||||
@@ -12,5 +12,8 @@ Check the environment: tools, config validity, paths, permissions
|
||||
\fB\-\-fix\fR
|
||||
Attempt to repair problems (create directories, fix the state file)
|
||||
.TP
|
||||
\fB\-\-watch\fR \fI<SECONDS>\fR
|
||||
Re\-run the checks every N seconds, alerting on failure (issue #59)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
|
||||
+7
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
history \- Search the command history (am and shell commands)
|
||||
.SH SYNOPSIS
|
||||
\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-\-save\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIRANGE\fR]
|
||||
.SH DESCRIPTION
|
||||
Search the command history (am and shell commands)
|
||||
.SH OPTIONS
|
||||
@@ -30,5 +30,11 @@ Maximum number of entries (default: 100)
|
||||
\fB\-\-rerun\fR \fI<N>\fR
|
||||
Re\-run the Nth most recent entry (with confirmation)
|
||||
.TP
|
||||
\fB\-\-save\fR \fI<FILE>\fR
|
||||
Export the range as a playbook YAML file (issue #51)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
[\fIRANGE\fR]
|
||||
Range to export as a playbook: "12..25" (1\-based, newest first)
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
init \- Generate a local agent\-manager.yaml for the current directory
|
||||
.SH SYNOPSIS
|
||||
\fBinit\fR [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
\fBinit\fR [\fB\-\-force\fR] [\fB\-\-template\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Generate a local agent\-manager.yaml for the current directory
|
||||
.SH OPTIONS
|
||||
@@ -12,5 +12,8 @@ Generate a local agent\-manager.yaml for the current directory
|
||||
\fB\-\-force\fR
|
||||
Overwrite an existing agent\-manager.yaml
|
||||
.TP
|
||||
\fB\-\-template\fR \fI<TEMPLATE>\fR
|
||||
Use a template (web, python, rust, cli) instead of stack detection; "list" shows the available templates
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
|
||||
+10
-1
@@ -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,28 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-lab 1 "lab "
|
||||
.SH NAME
|
||||
lab \- Benchmark: run the same task on several agents and compare (issue #62)
|
||||
.SH SYNOPSIS
|
||||
\fBlab\fR [\fB\-\-agents\fR] [\fB\-\-task\fR] [\fB\-\-parallel\fR] [\fB\-\-timeout\fR] [\fB\-\-list\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Benchmark: run the same task on several agents and compare (issue #62)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-agents\fR \fI<AGENTS>\fR
|
||||
Comma\-separated agent names (or aliases) to benchmark
|
||||
.TP
|
||||
\fB\-\-task\fR \fI<FILE>\fR
|
||||
Task file (a bare name resolves under state_dir/lab/, .yaml appended)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Run every agent concurrently instead of one after another
|
||||
.TP
|
||||
\fB\-\-timeout\fR \fI<SECS>\fR
|
||||
Override the task timeout in seconds
|
||||
.TP
|
||||
\fB\-\-list\fR
|
||||
List the task files of the lab directory
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -0,0 +1,22 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-migrate 1 "migrate "
|
||||
.SH NAME
|
||||
migrate \- Transfer the installation and state to another machine (issue #68)
|
||||
.SH SYNOPSIS
|
||||
\fBmigrate\fR [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIBUNDLE\fR]
|
||||
.SH DESCRIPTION
|
||||
Transfer the installation and state to another machine (issue #68)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-export\fR
|
||||
Export the bundle (default) or import one
|
||||
.TP
|
||||
\fB\-\-output\fR \fI<FILE>\fR
|
||||
Bundle path for export (default agent\-manager\-migrate.amx)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
[\fIBUNDLE\fR]
|
||||
Bundle path to import
|
||||
@@ -0,0 +1,25 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-models 1 "models "
|
||||
.SH NAME
|
||||
models \- List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.SH SYNOPSIS
|
||||
\fBmodels\fR [\fB\-\-prune\fR] [\fB\-\-dry\-run\fR] [\fB\-\-yes\fR] [\fB\-\-days\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-prune\fR
|
||||
Purge unused models instead of listing them
|
||||
.TP
|
||||
\fB\-\-dry\-run\fR
|
||||
Only list what would be pruned, delete nothing
|
||||
.TP
|
||||
\fB\-\-yes\fR
|
||||
Answer yes to the deletion prompt
|
||||
.TP
|
||||
\fB\-\-days\fR \fI<DAYS>\fR
|
||||
Consider a model unused after N days (default: 30)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -0,0 +1,19 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-monitor 1 "monitor "
|
||||
.SH NAME
|
||||
monitor \- Real\-time monitor of the managed processes (issue #50)
|
||||
.SH SYNOPSIS
|
||||
\fBmonitor\fR [\fB\-\-interval\fR] [\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Real\-time monitor of the managed processes (issue #50)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-interval\fR \fI<SECONDS>\fR
|
||||
Refresh interval in seconds (default 2)
|
||||
.TP
|
||||
\fB\-\-json\fR
|
||||
Print one JSON document per tick instead of the TUI
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -0,0 +1,19 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-playbook 1 "playbook "
|
||||
.SH NAME
|
||||
playbook \- Replay a saved playbook step by step with confirmation (issue #51)
|
||||
.SH SYNOPSIS
|
||||
\fBplaybook\fR [\fB\-\-var\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIFILE\fR>
|
||||
.SH DESCRIPTION
|
||||
Replay a saved playbook step by step with confirmation (issue #51)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-var\fR \fI<NAME=VALUE>\fR
|
||||
Variable values, repeatable: \-\-var name=value
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
<\fIFILE\fR>
|
||||
Playbook file (state_dir/playbooks/ when a bare name is given)
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-plugins 1 "plugins "
|
||||
.SH NAME
|
||||
plugins \- List the event plugins and test one (issue #75)
|
||||
.SH SYNOPSIS
|
||||
\fBplugins\fR [\fB\-\-test\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
List the event plugins and test one (issue #75)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-test\fR \fI<NAME>\fR
|
||||
Run one plugin against a synthetic event and print its response
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -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)
|
||||
+16
-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\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Restart an agent: stop, then start with the same options
|
||||
.SH OPTIONS
|
||||
@@ -27,6 +27,21 @@ Send a desktop notification once the agent has started
|
||||
\fB\-\-profile\fR \fI<NAME>\fR
|
||||
Apply an environment profile (env + args, defined in the config)
|
||||
.TP
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to use for this run (issue #92): resolved from the registry
|
||||
.TP
|
||||
\fB\-\-no\-sandbox\fR
|
||||
Skip the agent\*(Aqs sandbox profile for this run (issue #79)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Start every member of a group simultaneously (issue #57)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
\fB\-\-force\fR
|
||||
Kill immediately instead of terminating gracefully
|
||||
.TP
|
||||
|
||||
+13
-1
@@ -4,11 +4,23 @@
|
||||
.SH NAME
|
||||
run \- Run the agent command directly with the given arguments (no process management)
|
||||
.SH SYNOPSIS
|
||||
\fBrun\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
|
||||
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
|
||||
.SH DESCRIPTION
|
||||
Run the agent command directly with the given arguments (no process management)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
|
||||
.TP
|
||||
\fB\-\-no\-sandbox\fR
|
||||
Skip the agent\*(Aqs sandbox profile (issue #79)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-schedule 1 "schedule "
|
||||
.SH NAME
|
||||
schedule \- Schedule am commands (issue #56)
|
||||
.SH SYNOPSIS
|
||||
\fBschedule\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
|
||||
.SH DESCRIPTION
|
||||
Schedule am commands (issue #56)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
schedule\-add(1)
|
||||
Add a daily schedule (ex: \*(Aqam schedule add update \-\-all\*(Aq)
|
||||
.TP
|
||||
schedule\-list(1)
|
||||
List the schedules
|
||||
.TP
|
||||
schedule\-remove(1)
|
||||
Remove a schedule by id
|
||||
.TP
|
||||
schedule\-run(1)
|
||||
Run a schedule now (invoked by the OS task, issue #56)
|
||||
.TP
|
||||
schedule\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
+1
-1
@@ -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,26 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-service 1 "service "
|
||||
.SH NAME
|
||||
service \- Register an agent as a system service (issue #55)
|
||||
.SH SYNOPSIS
|
||||
\fBservice\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
|
||||
.SH DESCRIPTION
|
||||
Register an agent as a system service (issue #55)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
service\-install(1)
|
||||
Install the service unit (systemd / launchd / Task Scheduler)
|
||||
.TP
|
||||
service\-uninstall(1)
|
||||
Remove the service unit
|
||||
.TP
|
||||
service\-status(1)
|
||||
Show whether the service is installed
|
||||
.TP
|
||||
service\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
+10
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
sessions \- List the sessions of every agent (and of the interactive shell)
|
||||
.SH SYNOPSIS
|
||||
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-\-retention\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
List the sessions of every agent (and of the interactive shell)
|
||||
.SH OPTIONS
|
||||
@@ -21,6 +21,15 @@ Show one session in detail (summary + log excerpt)
|
||||
\fB\-\-resume\fR \fI<ID>\fR
|
||||
Resume a finished session: relaunch the agent with its recorded arguments
|
||||
.TP
|
||||
\fB\-\-export\fR \fI<ID>\fR
|
||||
Export one session (metadata, commands, log excerpt) to a JSON file
|
||||
.TP
|
||||
\fB\-\-output\fR \fI<FILE>\fR
|
||||
Destination of the export (default: ./am\-session\-<ID>.json)
|
||||
.TP
|
||||
\fB\-\-retention\fR \fI<DAYS>\fR
|
||||
Purge sessions finished more than N days ago (settings default 90)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-setup 1 "setup "
|
||||
.SH NAME
|
||||
setup \- Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
.SH SYNOPSIS
|
||||
\fBsetup\fR [\fB\-\-roles\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-roles\fR
|
||||
Only (re)generate the am\-* copilot roles for aichat
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
+16
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
start \- Start an agent (foreground by default, or detached with \-\-background)
|
||||
.SH SYNOPSIS
|
||||
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Start an agent (foreground by default, or detached with \-\-background)
|
||||
.SH OPTIONS
|
||||
@@ -27,6 +27,21 @@ Send a desktop notification once the agent has started
|
||||
\fB\-\-profile\fR \fI<NAME>\fR
|
||||
Apply an environment profile (env + args, defined in the config)
|
||||
.TP
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-provider\fR \fI<PROVIDER>\fR
|
||||
Provider to use for this run (issue #92): resolved from the registry
|
||||
.TP
|
||||
\fB\-\-no\-sandbox\fR
|
||||
Skip the agent\*(Aqs sandbox profile for this run (issue #79)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Start every member of a group simultaneously (issue #57)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
stats \- Show usage statistics computed from the event journal
|
||||
.SH SYNOPSIS
|
||||
\fBstats\fR [\fB\-\-period\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBstats\fR [\fB\-\-period\fR] [\fB\-\-costs\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Show usage statistics computed from the event journal
|
||||
.SH OPTIONS
|
||||
@@ -12,6 +12,9 @@ Show usage statistics computed from the event journal
|
||||
\fB\-\-period\fR \fI<PERIOD>\fR
|
||||
Only events of the last period (7d, 30d, 90d, all)
|
||||
.TP
|
||||
\fB\-\-costs\fR
|
||||
Show token usage and estimated spend per agent (issue #49)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-suggest 1 "suggest "
|
||||
.SH NAME
|
||||
suggest \- Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
.SH SYNOPSIS
|
||||
\fBsuggest\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIQUERY\fR>
|
||||
.SH DESCRIPTION
|
||||
Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
<\fIQUERY\fR>
|
||||
Free\-form request ("un agent pour du Python")
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-sync 1 "sync "
|
||||
.SH NAME
|
||||
sync \- Push the state into the configured git repository (issue #66)
|
||||
.SH SYNOPSIS
|
||||
\fBsync\fR [\fB\-\-message\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Push the state into the configured git repository (issue #66)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-message\fR \fI<MESSAGE>\fR
|
||||
Commit message (default: "am sync — state update")
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-theme 1 "theme "
|
||||
.SH NAME
|
||||
theme \- Set or show the active color theme (persisted for the next run)
|
||||
.SH SYNOPSIS
|
||||
\fBtheme\fR [\fB\-h\fR|\fB\-\-help\fR] [\fINAME\fR]
|
||||
.SH DESCRIPTION
|
||||
Set or show the active color theme (persisted for the next run)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
[\fINAME\fR]
|
||||
Theme name (omit to list available themes)
|
||||
@@ -0,0 +1,16 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-tip 1 "tip "
|
||||
.SH NAME
|
||||
tip \- Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
|
||||
.SH SYNOPSIS
|
||||
\fBtip\fR [\fB\-\-random\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-random\fR
|
||||
Show a single random tip instead of the whole page
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
update \- Update an installed agent to the latest available version
|
||||
.SH SYNOPSIS
|
||||
\fBupdate\fR [\fB\-\-all\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBupdate\fR [\fB\-\-all\fR] [\fB\-\-rollback\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Update an installed agent to the latest available version
|
||||
.SH OPTIONS
|
||||
@@ -12,6 +12,9 @@ Update an installed agent to the latest available version
|
||||
\fB\-\-all\fR
|
||||
Update every installed managed agent
|
||||
.TP
|
||||
\fB\-\-rollback\fR [\fI<ROLLBACK>\fR]
|
||||
Roll back to a pre\-update backup instead of updating ("latest" or a backup id; "\-\-rollback list" shows them)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-web 1 "web "
|
||||
.SH NAME
|
||||
web \- Local read\-only web dashboard with charts (issue #54)
|
||||
.SH SYNOPSIS
|
||||
\fBweb\fR [\fB\-\-port\fR] [\fB\-\-no\-open\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Local read\-only web dashboard with charts (issue #54)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-\-port\fR \fI<PORT>\fR
|
||||
Port to listen on (default 7878)
|
||||
.TP
|
||||
\fB\-\-no\-open\fR
|
||||
Do not open the browser automatically
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
@@ -1,10 +1,10 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.4.3"
|
||||
.TH am 1 "am 1.1.0"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
|
||||
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-\-lang\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
agent\-manager (am) — a CLI to manage local AI coding agents.
|
||||
.PP
|
||||
@@ -38,6 +38,19 @@ Disable ANSI colors
|
||||
\fB\-\-theme\fR \fI<THEME>\fR
|
||||
Color theme for the output (see \*(Aqam help theme\*(Aq for the list)
|
||||
.TP
|
||||
\fB\-\-lang\fR \fI<LANG>\fR
|
||||
Output language: fr (default) or en (issue #74)
|
||||
.br
|
||||
|
||||
.br
|
||||
\fIPossible values:\fR
|
||||
.RS 14
|
||||
.IP \(bu 2
|
||||
fr
|
||||
.IP \(bu 2
|
||||
en
|
||||
.RE
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help (see a summary with \*(Aq\-h\*(Aq)
|
||||
.TP
|
||||
@@ -84,12 +97,48 @@ Manage environment profiles (dev, prod, ...)
|
||||
am\-man(1)
|
||||
Generate a man page for am (or for one command)
|
||||
.TP
|
||||
am\-theme(1)
|
||||
Set or show the active color theme (persisted for the next run)
|
||||
.TP
|
||||
am\-alias(1)
|
||||
Manage command aliases (cc \-> claude\-code)
|
||||
.TP
|
||||
am\-secret(1)
|
||||
Manage secrets in the OS keyring (never in plaintext config)
|
||||
.TP
|
||||
am\-audit(1)
|
||||
Audit the configuration: who changed what, when (issue #63)
|
||||
.TP
|
||||
am\-service(1)
|
||||
Register an agent as a system service (issue #55)
|
||||
.TP
|
||||
am\-schedule(1)
|
||||
Schedule am commands (issue #56)
|
||||
.TP
|
||||
am\-models(1)
|
||||
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.TP
|
||||
am\-registry(1)
|
||||
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.TP
|
||||
am\-catalog(1)
|
||||
Manage remote catalogs: update the official one, add external ones
|
||||
.TP
|
||||
am\-providers(1)
|
||||
Manage the LLM provider registry: base URLs, models, default provider
|
||||
.TP
|
||||
am\-suggest(1)
|
||||
Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
.TP
|
||||
am\-ask(1)
|
||||
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
|
||||
.TP
|
||||
am\-ai(1)
|
||||
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||
.TP
|
||||
am\-setup(1)
|
||||
Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
.TP
|
||||
am\-start(1)
|
||||
Start an agent (foreground by default, or detached with \-\-background)
|
||||
.TP
|
||||
@@ -108,6 +157,21 @@ List the sessions of every agent (and of the interactive shell)
|
||||
am\-stats(1)
|
||||
Show usage statistics computed from the event journal
|
||||
.TP
|
||||
am\-monitor(1)
|
||||
Real\-time monitor of the managed processes (issue #50)
|
||||
.TP
|
||||
am\-web(1)
|
||||
Local read\-only web dashboard with charts (issue #54)
|
||||
.TP
|
||||
am\-serve(1)
|
||||
Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
|
||||
.TP
|
||||
am\-sync(1)
|
||||
Push the state into the configured git repository (issue #66)
|
||||
.TP
|
||||
am\-migrate(1)
|
||||
Transfer the installation and state to another machine (issue #68)
|
||||
.TP
|
||||
am\-top(1)
|
||||
Show the most used agents (top 10)
|
||||
.TP
|
||||
@@ -138,6 +202,15 @@ Search the catalog by keyword (name, description, category, tags)
|
||||
am\-history(1)
|
||||
Search the command history (am and shell commands)
|
||||
.TP
|
||||
am\-playbook(1)
|
||||
Replay a saved playbook step by step with confirmation (issue #51)
|
||||
.TP
|
||||
am\-lab(1)
|
||||
Benchmark: run the same task on several agents and compare (issue #62)
|
||||
.TP
|
||||
am\-plugins(1)
|
||||
List the event plugins and test one (issue #75)
|
||||
.TP
|
||||
am\-info(1)
|
||||
Show detailed information about one agent
|
||||
.TP
|
||||
@@ -150,6 +223,9 @@ Display help: the overview, a command, the command list, or a search
|
||||
am\-version(1)
|
||||
Display version and build information
|
||||
.TP
|
||||
am\-tip(1)
|
||||
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
|
||||
.TP
|
||||
am\-config(1)
|
||||
Manage the configuration file
|
||||
.TP
|
||||
@@ -174,4 +250,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.4.3
|
||||
v1.1.0
|
||||
|
||||
@@ -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,518 @@
|
||||
//! Post-install agent configuration (issue #91): the `config:` block of an
|
||||
//! agent definition wires the resolved provider/model into the agent —
|
||||
//! the environment variables it expects (env_map) and the config files to
|
||||
//! write or softly edit (files).
|
||||
//!
|
||||
//! Pure functions + per-format tests (probe.rs pattern). Security rule: a
|
||||
//! token is never written in clear — the api_key slot stays the `@secret`
|
||||
//! reference (issue #89), resolved from the OS keyring at start/run time.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{AgentDef, Config};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// The four format families covering ~every agent (spec #91).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileFormat {
|
||||
Toml,
|
||||
Yaml,
|
||||
Json,
|
||||
KeyValue,
|
||||
}
|
||||
|
||||
impl FileFormat {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Toml => "toml",
|
||||
FileFormat::Yaml => "yaml",
|
||||
FileFormat::Json => "json",
|
||||
FileFormat::KeyValue => "key=value",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the format family from the file extension.
|
||||
pub fn format_of(path: &Path) -> Option<FileFormat> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"toml" => Some(FileFormat::Toml),
|
||||
"yaml" | "yml" => Some(FileFormat::Yaml),
|
||||
"json" => Some(FileFormat::Json),
|
||||
"conf" | "ini" | "env" | "properties" | "cfg" => Some(FileFormat::KeyValue),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider preferred by an agent: `provider:` of the definition, then the
|
||||
/// `config.provider_default` of its config block (issue #91).
|
||||
pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
|
||||
agent
|
||||
.provider
|
||||
.as_deref()
|
||||
.or(agent.config.as_ref().and_then(|c| c.provider_default.as_deref()))
|
||||
}
|
||||
|
||||
/// Merge the resolved `config.env_map` of an agent into its default env
|
||||
/// (issue #91): api_key -> `@secret` (resolved by resolve_env_secrets),
|
||||
/// model/base_url/provider -> values of the resolved provider. Explicit
|
||||
/// values already present in agent.env are never overwritten.
|
||||
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
|
||||
apply_env_map_resolved(env, agent, config, None, None);
|
||||
}
|
||||
|
||||
/// Same as `apply_env_map` but with the provider and model fixed by the
|
||||
/// caller (issue #92: `am run/start --provider/--model`): the flags win
|
||||
/// over the agent declaration and the settings default.
|
||||
pub fn apply_env_map_resolved(
|
||||
env: &mut BTreeMap<String, String>,
|
||||
agent: &AgentDef,
|
||||
config: &Config,
|
||||
provider_override: Option<&str>,
|
||||
model_override: Option<&str>,
|
||||
) {
|
||||
let Some(cfg) = &agent.config else {
|
||||
return;
|
||||
};
|
||||
if cfg.env_map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(pname) = provider_override
|
||||
.filter(|p| !p.is_empty())
|
||||
.or_else(|| provider_pref(agent))
|
||||
.or_else(|| crate::providers::default_name(config))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(def) = crate::providers::get(config, pname) else {
|
||||
return;
|
||||
};
|
||||
let model = model_override
|
||||
.filter(|m| !m.is_empty())
|
||||
.or(agent.model.as_deref())
|
||||
.or(def.default_model.as_deref());
|
||||
for (slot, var) in &cfg.env_map {
|
||||
let value = match slot.as_str() {
|
||||
"api_key" => "@secret".to_string(),
|
||||
"model" => {
|
||||
let Some(m) = model else { continue };
|
||||
m.to_string()
|
||||
}
|
||||
"base_url" => def.base_url.clone(),
|
||||
"provider" => pname.to_string(),
|
||||
_ => continue, // unknown slots are ignored
|
||||
};
|
||||
env.entry(var.clone()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the agent configuration files after a successful install
|
||||
/// (issue #91). The caller skips this on `--no-config`, on non-configurable
|
||||
/// agents, and in dry-run (never reached). Never writes a token in clear:
|
||||
/// the api_key slot is written as the `@secret` reference.
|
||||
pub fn apply_post_install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
) -> Result<()> {
|
||||
let Some(cfg) = &agent.config else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"agent sans adaptateur de config — voir sa doc",
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
if cfg.files.is_empty() {
|
||||
if cfg.env_map.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"agent sans adaptateur de config — voir sa doc",
|
||||
));
|
||||
} else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"config par variables d'env — injectée au démarrage",
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some((pname, model)) = resolved else {
|
||||
app.log.warn(crate::i18n::tr(
|
||||
"aucun provider résolu — fichiers de config non écrits",
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
let Some(def) = crate::providers::get(&app.config, pname) else {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"provider '{}' introuvable — fichiers de config non écrits",
|
||||
pname
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
for f in &cfg.files {
|
||||
let path = crate::config::expand_path(&f.path);
|
||||
let Some(fmt) = format_of(&path) else {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"format inconnu pour {} — adaptateurs: toml, yaml, json, key=value",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let mut updates: Vec<(String, String)> = Vec::new();
|
||||
for key in &f.keys {
|
||||
let value = match key.as_str() {
|
||||
"api_key" => "@secret".to_string(),
|
||||
"model" => model.clone().unwrap_or_default(),
|
||||
"base_url" => def.base_url.clone(),
|
||||
"provider" => pname.clone(),
|
||||
other => {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"clé '{}' inconnue dans config.files de {}",
|
||||
other,
|
||||
agent.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
updates.push((key.clone(), value));
|
||||
}
|
||||
write_soft(&path, fmt, &updates)?;
|
||||
app.log
|
||||
.success(&crate::tr_fmt!("config écrite: {}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft edit of a config file: existing unknown keys are preserved, known
|
||||
/// keys are updated, missing keys are appended. Creates parent dirs.
|
||||
pub fn write_soft(path: &Path, format: FileFormat, updates: &[(String, String)]) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
let text = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let out = match format {
|
||||
FileFormat::Toml => edit_toml(&text, updates)?,
|
||||
FileFormat::Yaml => edit_yaml(&text, updates)?,
|
||||
FileFormat::Json => edit_json(&text, updates)?,
|
||||
FileFormat::KeyValue => edit_key_value(&text, updates),
|
||||
};
|
||||
std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TOML soft edit. Note: toml::Value does not retain comments on
|
||||
/// serialization — unknown KEYS are preserved, comments are dropped.
|
||||
fn edit_toml(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: toml::Value = if text.trim().is_empty() {
|
||||
toml::Value::Table(Default::default())
|
||||
} else {
|
||||
text.parse::<toml::Value>()
|
||||
.map_err(|e| anyhow!("invalid TOML: {e}"))?
|
||||
};
|
||||
let table = root
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| anyhow!("TOML root is not a table"))?;
|
||||
for (k, v) in updates {
|
||||
table.insert(k.clone(), toml::Value::String(v.clone()));
|
||||
}
|
||||
Ok(root.to_string())
|
||||
}
|
||||
|
||||
fn edit_yaml(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: serde_yaml::Value = if text.trim().is_empty() {
|
||||
serde_yaml::Value::Mapping(Default::default())
|
||||
} else {
|
||||
serde_yaml::from_str(text).map_err(|e| anyhow!("invalid YAML: {e}"))?
|
||||
};
|
||||
let map = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("YAML root is not a mapping"))?;
|
||||
for (k, v) in updates {
|
||||
map.insert(
|
||||
serde_yaml::Value::String(k.clone()),
|
||||
serde_yaml::Value::String(v.clone()),
|
||||
);
|
||||
}
|
||||
serde_yaml::to_string(&root).map_err(|e| anyhow!("cannot serialize YAML: {e}"))
|
||||
}
|
||||
|
||||
fn edit_json(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: serde_json::Value = if text.trim().is_empty() {
|
||||
serde_json::Value::Object(Default::default())
|
||||
} else {
|
||||
serde_json::from_str(text).map_err(|e| anyhow!("invalid JSON: {e}"))?
|
||||
};
|
||||
let obj = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("JSON root is not an object"))?;
|
||||
for (k, v) in updates {
|
||||
obj.insert(k.clone(), serde_json::Value::String(v.clone()));
|
||||
}
|
||||
serde_json::to_string_pretty(&root).map_err(|e| anyhow!("cannot serialize JSON: {e}"))
|
||||
}
|
||||
|
||||
fn edit_key_value(text: &str, updates: &[(String, String)]) -> String {
|
||||
let mut lines: Vec<String> = text.lines().map(String::from).collect();
|
||||
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for (i, line) in lines.iter_mut().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.contains('=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = trimmed.split('=').next().unwrap_or("").trim();
|
||||
// Case-insensitive match (API_KEY vs api_key in .env files); the
|
||||
// existing line keeps its casing.
|
||||
if let Some((k, v)) = updates.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
|
||||
*line = format!("{key}={v}");
|
||||
seen.insert(k, i);
|
||||
}
|
||||
}
|
||||
for (k, v) in updates {
|
||||
if !seen.contains_key(k.as_str()) {
|
||||
lines.push(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use crate::config::{AgentConfig, Config};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn updates() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("model".to_string(), "gpt-5.2".to_string()),
|
||||
("base_url".to_string(), "https://api.openai.com/v1".to_string()),
|
||||
("api_key".to_string(), "@secret".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn tmp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join(name);
|
||||
if !content.is_empty() {
|
||||
std::fs::write(&p, content).unwrap();
|
||||
}
|
||||
(dir, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_detection_covers_the_four_families() {
|
||||
assert_eq!(format_of(Path::new("a.toml")), Some(FileFormat::Toml));
|
||||
assert_eq!(format_of(Path::new("a.yaml")), Some(FileFormat::Yaml));
|
||||
assert_eq!(format_of(Path::new("a.yml")), Some(FileFormat::Yaml));
|
||||
assert_eq!(format_of(Path::new("a.json")), Some(FileFormat::Json));
|
||||
assert_eq!(format_of(Path::new("a.conf")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.env")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.ini")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.md")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.toml", "# existing\nmodel = \"old\"\ntemperature = 0.7\n");
|
||||
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
|
||||
assert!(
|
||||
out.contains("base_url = \"https://api.openai.com/v1\""),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("api_key = \"@secret\""), "{out}");
|
||||
assert!(out.contains("temperature = 0.7"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_creates_the_file_when_missing() {
|
||||
let (_d, p) = tmp("new.toml", "");
|
||||
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
|
||||
assert!(out.contains("api_key = \"@secret\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.yaml", "model: old\nverbose: true\n");
|
||||
write_soft(&p, FileFormat::Yaml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model: gpt-5.2"), "{out}");
|
||||
assert!(out.contains("base_url: https://api.openai.com/v1"), "{out}");
|
||||
assert!(out.contains("verbose: true"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.json", "{\"model\": \"old\", \"verbose\": true}");
|
||||
write_soft(&p, FileFormat::Json, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("\"model\": \"gpt-5.2\""), "{out}");
|
||||
assert!(out.contains("\"api_key\": \"@secret\""), "{out}");
|
||||
assert!(out.contains("\"verbose\": true"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_round_trip_keeps_unknown_lines() {
|
||||
let (_d, p) = tmp(
|
||||
"c.conf",
|
||||
"# header\nMODEL=old\nOTHER=keep\n",
|
||||
);
|
||||
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("MODEL=gpt-5.2"), "{out}");
|
||||
assert!(out.contains("OTHER=keep"), "unknown line lost: {out}");
|
||||
assert!(out.contains("# header"), "comment lost: {out}");
|
||||
assert!(out.contains("api_key=@secret"), "{out}");
|
||||
// No duplicate MODEL line.
|
||||
assert_eq!(out.matches("MODEL=").count(), 1, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_appends_missing_keys() {
|
||||
let (_d, p) = tmp("empty.env", "");
|
||||
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model=gpt-5.2"), "{out}");
|
||||
assert!(out.contains("base_url=https://api.openai.com/v1"), "{out}");
|
||||
assert!(out.contains("api_key=@secret"), "{out}");
|
||||
}
|
||||
|
||||
fn agent_with_config(config: AgentConfig, provider: Option<String>) -> AgentDef {
|
||||
AgentDef {
|
||||
name: "x".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
category: None,
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("x".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: None,
|
||||
model_arg: None,
|
||||
provider,
|
||||
model: None,
|
||||
config: Some(config),
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_map_wires_the_resolved_provider() {
|
||||
let mut c: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
|
||||
env_map.insert("model".to_string(), "MY_MODEL".to_string());
|
||||
env_map.insert("base_url".to_string(), "MY_URL".to_string());
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map,
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
apply_env_map(&mut env, &agent, &c);
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
// Explicit agent.env values win.
|
||||
let mut env2 = BTreeMap::new();
|
||||
env2.insert("MY_MODEL".to_string(), "explicit".to_string());
|
||||
apply_env_map(&mut env2, &agent, &c);
|
||||
assert_eq!(env2.get("MY_MODEL").unwrap(), "explicit");
|
||||
// Unset default provider + no provider_default: no-op.
|
||||
c.settings.default_provider = None;
|
||||
let mut env3 = BTreeMap::new();
|
||||
apply_env_map(&mut env3, &agent, &c);
|
||||
assert!(env3.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_post_install_without_config_block_is_a_noop() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app = crate::app::App::from_cli(cli).unwrap();
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
// No config block at all → Ok, nothing written.
|
||||
let mut bare = agent.clone();
|
||||
bare.config = None;
|
||||
assert!(apply_post_install(&app, &bare, &None).is_ok());
|
||||
// Config block but no files → Ok, nothing written.
|
||||
assert!(apply_post_install(&app, &agent, &None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_pref_prefers_the_declared_provider() {
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: Some("openai".to_string()),
|
||||
},
|
||||
Some("anthropic".to_string()),
|
||||
);
|
||||
assert_eq!(provider_pref(&agent), Some("anthropic"));
|
||||
let agent2 = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: Some("openai".to_string()),
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(provider_pref(&agent2), Some("openai"));
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -21,6 +21,9 @@ pub struct App {
|
||||
pub catalog: Catalog,
|
||||
pub state: StateStore,
|
||||
pub log: Logger,
|
||||
/// Active output language (issue #74); mirrored in i18n::active() so the
|
||||
/// REPL 'lang' command can switch it at runtime.
|
||||
lang: Cell<crate::i18n::Lang>,
|
||||
/// Active output color theme (switchable at runtime with the REPL
|
||||
/// 'theme <name>' command).
|
||||
theme: Cell<&'static Theme>,
|
||||
@@ -84,6 +87,8 @@ impl App {
|
||||
loaded.config.groups.clone(),
|
||||
);
|
||||
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
|
||||
let lang = crate::i18n::resolve(cli.lang);
|
||||
crate::i18n::set_lang(lang);
|
||||
Ok(Self {
|
||||
cli,
|
||||
config: loaded.config,
|
||||
@@ -92,6 +97,7 @@ impl App {
|
||||
catalog,
|
||||
state,
|
||||
log,
|
||||
lang: Cell::new(lang),
|
||||
theme: Cell::new(theme),
|
||||
})
|
||||
}
|
||||
@@ -116,6 +122,18 @@ impl App {
|
||||
self.theme.get()
|
||||
}
|
||||
|
||||
/// Active output language (issue #74).
|
||||
pub fn lang(&self) -> crate::i18n::Lang {
|
||||
self.lang.get()
|
||||
}
|
||||
|
||||
/// Switch the active language (REPL 'lang' command); the i18n global is
|
||||
/// kept in sync so tr()/tr_fmt! follow immediately.
|
||||
pub fn set_lang(&self, lang: crate::i18n::Lang) {
|
||||
self.lang.set(lang);
|
||||
crate::i18n::set_lang(lang);
|
||||
}
|
||||
|
||||
/// Switch the active theme (used by the REPL 'theme <name>' command).
|
||||
pub fn set_theme(&self, theme: &'static Theme) {
|
||||
self.theme.set(theme);
|
||||
@@ -131,11 +149,15 @@ impl App {
|
||||
}
|
||||
|
||||
/// Append an event to the journal; failures are never fatal (logged at
|
||||
/// verbose level only).
|
||||
/// verbose level only). The plugins subscribed to the event kind are
|
||||
/// then fired (issue #75) — non-blocking, never fatal either.
|
||||
pub fn emit(&self, event: &crate::events::Event) {
|
||||
if let Err(e) = crate::events::append(&self.events_dir(), event) {
|
||||
self.log.verbose(&format!("cannot write event journal: {e:#}"));
|
||||
}
|
||||
// Issue #76: aggregated anonymous counters (no-op while disabled).
|
||||
crate::telemetry::maybe_record(self, event);
|
||||
crate::plugins::dispatch(self, event);
|
||||
}
|
||||
|
||||
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
|
||||
|
||||
+540
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Automation helpers (axe 6): system service units, scheduled tasks,
|
||||
//! container commands and health checks. Pure functions here are unit
|
||||
//! tested; the thin command layers live in commands/service_cmd.rs,
|
||||
//! commands/schedule_cmd.rs, run_cmd.rs and doctor_cmd.rs.
|
||||
|
||||
use crate::config::{ContainerSpec, HealthCheck};
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Am binary path used inside generated units/tasks (quoted).
|
||||
pub fn am_bin_quoted() -> String {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "am".to_string());
|
||||
shell_escape(&exe)
|
||||
}
|
||||
|
||||
fn shell_escape(s: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #55: system service units
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// systemd user unit for 'am service install <agent> --autostart'.
|
||||
pub fn systemd_unit(agent: &str, am_bin: &str) -> String {
|
||||
format!(
|
||||
"# Generated by 'am service install {agent}' — managed by agent-manager\n\
|
||||
[Unit]\n\
|
||||
Description=agent-manager: {agent}\n\
|
||||
After=network-online.target\n\
|
||||
Wants=network-online.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
ExecStart={am_bin} start {agent} --background --notify\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=10\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// launchd plist for macOS.
|
||||
pub fn launchd_plist(agent: &str, am_bin: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||
<plist version=\"1.0\">\n\
|
||||
<dict>\n\
|
||||
\t<key>Label</key>\n\
|
||||
\t<string>com.agent-manager.{agent}</string>\n\
|
||||
\t<key>ProgramArguments</key>\n\
|
||||
\t<array>\n\
|
||||
\t\t<string>{am_bin}</string>\n\
|
||||
\t\t<string>start</string>\n\
|
||||
\t\t<string>{agent}</string>\n\
|
||||
\t\t<string>--background</string>\n\
|
||||
\t\t<string>--notify</string>\n\
|
||||
\t</array>\n\
|
||||
\t<key>RunAtLoad</key>\n\
|
||||
\t<true/>\n\
|
||||
\t<key>KeepAlive</key>\n\
|
||||
\t<true/>\n\
|
||||
</dict>\n\
|
||||
</plist>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows scheduled task XML (on logon, with retry).
|
||||
pub fn windows_task_xml(agent: &str, am_bin: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||
\t<Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>\n\
|
||||
\t<Principals><Principal id=\"Author\"><LogonType>InteractiveToken</LogonType>\n\
|
||||
\t<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n\
|
||||
\t<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||
\t<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
|
||||
\t<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
|
||||
\t<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>\n\
|
||||
\t</Settings>\n\
|
||||
\t<Actions><Exec>\n\
|
||||
\t<Command>{am_bin}</Command>\n\
|
||||
\t<Arguments>start {agent} --background --notify</Arguments>\n\
|
||||
\t</Exec></Actions>\n\
|
||||
</Task>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Path of the systemd user unit for an agent.
|
||||
pub fn systemd_unit_path(agent: &str) -> PathBuf {
|
||||
let base = std::env::var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
crate::config::home_dir()
|
||||
.map(|h| h.join(".config"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config"))
|
||||
});
|
||||
base.join("systemd").join("user").join(format!("am-{agent}.service"))
|
||||
}
|
||||
|
||||
/// Path of the launchd plist for an agent.
|
||||
pub fn launchd_path(agent: &str) -> PathBuf {
|
||||
crate::config::home_dir()
|
||||
.map(|h| h.join("Library/LaunchAgents"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(format!("com.agent-manager.{agent}.plist"))
|
||||
}
|
||||
|
||||
/// Windows task name for an agent.
|
||||
pub fn windows_task_name(agent: &str) -> String {
|
||||
format!("am-{agent}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #56: scheduled tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HH:MM at which a "daily" schedule runs (default 08:00). Parsed leniently.
|
||||
pub fn parse_hhmm(at: &str) -> (u8, u8) {
|
||||
let parts: Vec<&str> = at.split(':').collect();
|
||||
let h: u8 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(8);
|
||||
let m: u8 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
(h.min(23), m.min(59))
|
||||
}
|
||||
|
||||
/// Crontab line for a daily schedule (Linux/macOS).
|
||||
pub fn crontab_line(at: &str, am_bin: &str, schedule_id: &str) -> String {
|
||||
let (h, m) = parse_hhmm(at);
|
||||
format!("{m} {h} * * * {am_bin} schedule-run {schedule_id}")
|
||||
}
|
||||
|
||||
/// schtasks command registering a daily task (Windows).
|
||||
pub fn schtasks_daily(at: &str, am_bin: &str, schedule_id: &str, task_name: &str) -> Command {
|
||||
let (h, m) = parse_hhmm(at);
|
||||
let mut c = Command::new("schtasks");
|
||||
c.args([
|
||||
"/create",
|
||||
"/tn",
|
||||
task_name,
|
||||
"/sc",
|
||||
"daily",
|
||||
"/st",
|
||||
&format!("{h:02}:{m:02}"),
|
||||
"/tr",
|
||||
&format!("\"{am_bin}\" schedule-run {schedule_id}"),
|
||||
"/f",
|
||||
]);
|
||||
c
|
||||
}
|
||||
|
||||
/// The command that actually runs a schedule: am schedule-run <id>.
|
||||
pub fn schedule_run_command(app: &crate::app::App, entry: &crate::state::ScheduleEntry) -> Result<i32> {
|
||||
// Re-dispatch through the CLI so hooks/events/state stay consistent.
|
||||
let args: Vec<String> = entry
|
||||
.command
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let mut full = vec!["am".to_string()];
|
||||
if let Some(cfg) = &app.config_origin {
|
||||
full.push("--config".to_string());
|
||||
full.push(cfg.to_string_lossy().to_string());
|
||||
}
|
||||
full.extend(args);
|
||||
let status = Command::new(std::env::current_exe()?)
|
||||
.args(&full[1..])
|
||||
.status()?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #57: health checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wait for an agent health check (issue #57). Returns Ok when the check
|
||||
/// passes within the timeout, Err otherwise. Never probes when the agent
|
||||
/// has no healthcheck configured.
|
||||
pub fn wait_health(agent: &str, hc: Option<&HealthCheck>, log: &crate::output::Logger) -> Result<()> {
|
||||
let Some(hc) = hc else {
|
||||
return Ok(());
|
||||
};
|
||||
let timeout = hc.timeout_secs.unwrap_or(30).max(1);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let ok = healthcheck_once(hc);
|
||||
if ok {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
bail!(
|
||||
"health check for '{agent}' failed after {timeout}s \
|
||||
(command: {}, port: {})",
|
||||
hc.command.as_deref().unwrap_or("-"),
|
||||
hc.port.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
}
|
||||
if attempt == 1 || attempt % 5 == 0 {
|
||||
log.verbose(&format!(
|
||||
"waiting for '{agent}' health (attempt {attempt})..."
|
||||
));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
/// One health check probe: command exit 0, or TCP connect on the port.
|
||||
pub fn healthcheck_once(hc: &HealthCheck) -> bool {
|
||||
if let Some(cmd) = &hc.command {
|
||||
let ok = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(port) = hc.port {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #58: container profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect a container runtime: podman preferred, docker fallback.
|
||||
pub fn detect_runtime() -> Option<String> {
|
||||
for bin in ["podman", "docker"] {
|
||||
if Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(bin.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the container run command for an agent (issue #58): mounts the
|
||||
/// state dir and log dir, applies the profile volumes/network/env, runs
|
||||
/// the agent's run command in the image.
|
||||
pub fn container_command(
|
||||
runtime: &str,
|
||||
agent: &str,
|
||||
spec: &ContainerSpec,
|
||||
state_dir: &Path,
|
||||
log_dir: &Path,
|
||||
) -> Result<Vec<String>> {
|
||||
if spec.image.trim().is_empty() {
|
||||
bail!("agent '{agent}': container profile requires an 'image'");
|
||||
}
|
||||
let mut cmd = vec![
|
||||
runtime.to_string(),
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
format!("am-{agent}"),
|
||||
];
|
||||
// State + logs mounts so sessions/history keep working.
|
||||
cmd.push("-v".to_string());
|
||||
cmd.push(format!("{}:/data", state_dir.to_string_lossy()));
|
||||
cmd.push("-v".to_string());
|
||||
cmd.push(format!("{}:/logs", log_dir.to_string_lossy()));
|
||||
for v in &spec.volumes {
|
||||
cmd.push("-v".to_string());
|
||||
cmd.push(v.clone());
|
||||
}
|
||||
match &spec.network {
|
||||
Some(n) => {
|
||||
cmd.push("--network".to_string());
|
||||
cmd.push(n.clone());
|
||||
}
|
||||
None => {
|
||||
cmd.push("--network".to_string());
|
||||
cmd.push(
|
||||
if cfg!(target_os = "linux") {
|
||||
"host".to_string()
|
||||
} else {
|
||||
"bridge".to_string()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
for (k, v) in &spec.env {
|
||||
cmd.push("-e".to_string());
|
||||
cmd.push(format!("{k}={v}"));
|
||||
}
|
||||
cmd.push(spec.image.clone());
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn systemd_unit_is_valid_and_referenced() {
|
||||
let unit = systemd_unit("claude-code", "/usr/local/bin/am");
|
||||
assert!(unit.contains("[Unit]"));
|
||||
assert!(unit.contains("ExecStart=/usr/local/bin/am start claude-code --background --notify"));
|
||||
assert!(unit.contains("Restart=on-failure"));
|
||||
assert!(unit.contains("[Install]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_is_valid_xml_shape() {
|
||||
let plist = launchd_plist("aider", "/opt/homebrew/bin/am");
|
||||
assert!(plist.contains("com.agent-manager.aider"));
|
||||
assert!(plist.contains("<string>/opt/homebrew/bin/am</string>"));
|
||||
assert!(plist.contains("<key>RunAtLoad</key>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_task_xml_has_action() {
|
||||
let xml = windows_task_xml("codex", "C:\\bin\\am.exe");
|
||||
assert!(xml.contains("LogonTrigger"));
|
||||
assert!(xml.contains("am.exe"));
|
||||
assert!(xml.contains("start codex --background --notify"));
|
||||
assert!(xml.contains("RestartOnFailure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crontab_line_formats_time_and_id() {
|
||||
let line = crontab_line("22:05", "/usr/bin/am", "sched-1");
|
||||
assert_eq!(line, "5 22 * * * /usr/bin/am schedule-run sched-1");
|
||||
let line = crontab_line("", "/usr/bin/am", "sched-2");
|
||||
assert_eq!(line, "0 8 * * * /usr/bin/am schedule-run sched-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hhmm_is_lenient() {
|
||||
assert_eq!(parse_hhmm("09:30"), (9, 30));
|
||||
assert_eq!(parse_hhmm("garbage"), (8, 0));
|
||||
assert_eq!(parse_hhmm("25:99"), (23, 59));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthcheck_command_exit_0_is_healthy() {
|
||||
let hc = HealthCheck {
|
||||
command: Some("true".to_string()),
|
||||
port: None,
|
||||
timeout_secs: Some(5),
|
||||
};
|
||||
assert!(healthcheck_once(&hc));
|
||||
let bad = HealthCheck {
|
||||
command: Some("false".to_string()),
|
||||
port: None,
|
||||
timeout_secs: Some(5),
|
||||
};
|
||||
assert!(!healthcheck_once(&bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthcheck_without_config_skips() {
|
||||
let app = test_app();
|
||||
assert!(wait_health("x", None, &app.log).is_ok());
|
||||
}
|
||||
|
||||
fn test_app() -> crate::app::App {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
" confirm_before_run: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
use clap::Parser;
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
app.paths.state_file = dir.path().join("state.json");
|
||||
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_command_mounts_and_env() {
|
||||
let spec = ContainerSpec {
|
||||
image: "node:20".to_string(),
|
||||
volumes: vec!["/host/work:/work:ro".to_string()],
|
||||
network: None,
|
||||
env: BTreeMap::from([("NODE_ENV".to_string(), "production".to_string())]),
|
||||
};
|
||||
let cmd = container_command(
|
||||
"docker",
|
||||
"my-agent",
|
||||
&spec,
|
||||
Path::new("/state"),
|
||||
Path::new("/logs"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd[0], "docker");
|
||||
assert!(cmd.contains(&"run".to_string()));
|
||||
assert!(cmd.contains(&"-v".to_string()));
|
||||
assert!(cmd.contains(&"/state:/data".to_string()));
|
||||
assert!(cmd.contains(&"/logs:/logs".to_string()));
|
||||
assert!(cmd.contains(&"--network".to_string()));
|
||||
assert!(cmd.contains(&"-e".to_string()));
|
||||
assert!(cmd.contains(&"NODE_ENV=production".to_string()));
|
||||
assert_eq!(cmd.last().unwrap(), "node:20");
|
||||
// Empty image fails clearly.
|
||||
let bad = ContainerSpec { image: String::new(), ..Default::default() };
|
||||
assert!(container_command("docker", "a", &bad, Path::new("/s"), Path::new("/l")).is_err());
|
||||
}
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
//! Pre-update backups (issue #64, axe 8): a snapshot of the install
|
||||
//! directory, the state database and the user config is taken before every
|
||||
//! real update, kept under <state>/backups/<timestamp>/, and restored by
|
||||
//! 'am update --rollback <point>'. Retention is bounded by
|
||||
//! settings.backups_keep (default 5).
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Default number of backups to keep (settings.backups_keep default).
|
||||
pub const DEFAULT_KEEP: u32 = 5;
|
||||
|
||||
/// Manifest of one backup point (JSON file inside the backup directory).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupInfo {
|
||||
/// Backup id = the directory name (RFC 3339 timestamp).
|
||||
pub id: String,
|
||||
/// RFC 3339 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// Reason: "update:<agent>" or "update:--all".
|
||||
pub reason: String,
|
||||
/// Agents whose install directories were snapshotted (empty = all).
|
||||
pub agents: Vec<String>,
|
||||
/// True when the state database was snapshotted.
|
||||
pub state: bool,
|
||||
/// True when the user config file was snapshotted.
|
||||
pub config: bool,
|
||||
}
|
||||
|
||||
impl BackupInfo {
|
||||
/// Load the manifest of one backup directory.
|
||||
fn load(dir: &Path) -> Option<BackupInfo> {
|
||||
let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory holding every backup point. Lives next to the state database
|
||||
/// so tests and --config overrides stay fully sandboxed.
|
||||
pub fn backups_dir(app: &App) -> PathBuf {
|
||||
app.paths
|
||||
.state_file
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
/// Take a snapshot before an update. `agents` lists the agents being
|
||||
/// updated; an empty list means "the whole install directory".
|
||||
pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result<BackupInfo> {
|
||||
let now = crate::installers::now_rfc3339();
|
||||
// Directory names must be sortable and filesystem-safe: the RFC 3339
|
||||
// timestamp with colons replaced by dashes.
|
||||
let id = now.replace(':', "-").replace('Z', "").replace('+', "_");
|
||||
let root = backups_dir(app).join(&id);
|
||||
std::fs::create_dir_all(&root)
|
||||
.with_context(|| format!("cannot create backup directory {}", root.display()))?;
|
||||
|
||||
let mut info = BackupInfo {
|
||||
id: id.clone(),
|
||||
created_at: now,
|
||||
reason: reason.to_string(),
|
||||
agents: agents.to_vec(),
|
||||
state: false,
|
||||
config: false,
|
||||
};
|
||||
|
||||
// 1. State database.
|
||||
if let Ok(sf) = app.state.load() {
|
||||
let text = serde_json::to_string_pretty(&sf)?;
|
||||
std::fs::write(root.join("state.json"), text)
|
||||
.with_context(|| "cannot snapshot state.json".to_string())?;
|
||||
info.state = true;
|
||||
}
|
||||
|
||||
// 2. User config file (the active origin when it is a user file).
|
||||
if let Some(origin) = &app.config_origin {
|
||||
if origin.is_file() {
|
||||
std::fs::copy(origin, root.join("config.yaml")).with_context(|| {
|
||||
format!("cannot snapshot user config {}", origin.display())
|
||||
})?;
|
||||
info.config = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Install directories of the updated agents (or everything).
|
||||
let install_root = &app.paths.install_dir;
|
||||
let dest_root = root.join("install");
|
||||
std::fs::create_dir_all(&dest_root)?;
|
||||
if agents.is_empty() {
|
||||
copy_tree(install_root, &dest_root)
|
||||
.with_context(|| format!("cannot snapshot {}", install_root.display()))?;
|
||||
} else {
|
||||
for name in agents {
|
||||
let entry = app.state.get(name).ok().flatten();
|
||||
if let Some(e) = entry {
|
||||
let src = PathBuf::from(&e.install_dir);
|
||||
if src.is_dir() {
|
||||
let dest = dest_root.join(&e.name);
|
||||
copy_tree(&src, &dest).with_context(|| {
|
||||
format!("cannot snapshot {} for {name}", src.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// Also snapshot the shim binaries (bin/<name>).
|
||||
let shim = app.paths.bin_dir.join(name);
|
||||
if shim.exists() {
|
||||
copy_tree(&shim, &dest_root.join("bin").join(name))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the manifest last: a backup without a manifest is invalid.
|
||||
let manifest = serde_json::to_string_pretty(&info)?;
|
||||
std::fs::write(root.join("manifest.json"), manifest)
|
||||
.with_context(|| "cannot write backup manifest".to_string())?;
|
||||
|
||||
prune(app)?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// List every backup point, oldest first.
|
||||
pub fn list_backups(app: &App) -> Vec<BackupInfo> {
|
||||
let mut out: Vec<BackupInfo> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(backups_dir(app)) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(info) = BackupInfo::load(&path) {
|
||||
out.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve a rollback point: "latest"/empty = most recent backup,
|
||||
/// otherwise the backup id (prefix matching allowed).
|
||||
pub fn resolve_point(app: &App, point: Option<&str>) -> Result<BackupInfo> {
|
||||
let all = list_backups(app);
|
||||
if all.is_empty() {
|
||||
bail!("no backup found — nothing to roll back to");
|
||||
}
|
||||
match point {
|
||||
None | Some("latest") => all
|
||||
.last()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("no backup found")),
|
||||
Some(p) => {
|
||||
let exact = all.iter().find(|b| b.id == p);
|
||||
if let Some(b) = exact {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let prefix: Vec<BackupInfo> = all
|
||||
.iter()
|
||||
.filter(|b| b.id.starts_with(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
match prefix.len() {
|
||||
0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"),
|
||||
1 => Ok(prefix[0].clone()),
|
||||
_ => bail!(
|
||||
"backup prefix '{p}' is ambiguous ({} matches: {})",
|
||||
prefix.len(),
|
||||
prefix
|
||||
.iter()
|
||||
.map(|b| b.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a backup point over the live install: state, user config and
|
||||
/// install directories. The agent processes of the restored agents are
|
||||
/// stopped first (they would hold the old binaries open).
|
||||
pub fn restore_backup(app: &App, point: Option<&str>) -> Result<BackupInfo> {
|
||||
let info = resolve_point(app, point)?;
|
||||
let root = backups_dir(app).join(&info.id);
|
||||
if !root.join("manifest.json").is_file() {
|
||||
bail!("backup {} is corrupted (no manifest)", info.id);
|
||||
}
|
||||
|
||||
// Stop the agents being restored (only managed ones we know).
|
||||
let names: Vec<String> = if info.agents.is_empty() {
|
||||
app.state
|
||||
.all()?
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
info.agents.clone()
|
||||
};
|
||||
for name in &names {
|
||||
if let Ok(Some(entry)) = app.state.get(name) {
|
||||
if let Some(pid) = entry.pid {
|
||||
if crate::process::is_running(pid) {
|
||||
app.log
|
||||
.info(&format!("stopping {name} (pid {pid}) before rollback"));
|
||||
let _ = crate::commands::run_cmd::stop(app, name, false, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Restore the state database.
|
||||
if info.state {
|
||||
let src = root.join("state.json");
|
||||
if src.is_file() {
|
||||
std::fs::copy(&src, app.state.path())
|
||||
.with_context(|| "cannot restore state.json".to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Restore the user config.
|
||||
if info.config {
|
||||
if let Some(origin) = &app.config_origin {
|
||||
if let Some(parent) = origin.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(root.join("config.yaml"), origin)
|
||||
.with_context(|| format!("cannot restore {}", origin.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Restore the install directories.
|
||||
let src_root = root.join("install");
|
||||
if src_root.is_dir() {
|
||||
if info.agents.is_empty() {
|
||||
if app.paths.install_dir.exists() {
|
||||
std::fs::remove_dir_all(&app.paths.install_dir)
|
||||
.with_context(|| "cannot clear the install directory".to_string())?;
|
||||
}
|
||||
copy_tree(&src_root, &app.paths.install_dir)?;
|
||||
} else {
|
||||
for name in &info.agents {
|
||||
let src = src_root.join(name);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
// Remove the agent's current install dir (known from the
|
||||
// restored state) and copy the snapshot back.
|
||||
if let Ok(Some(entry)) = app.state.get(name) {
|
||||
let cur = PathBuf::from(&entry.install_dir);
|
||||
if cur.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&cur);
|
||||
}
|
||||
}
|
||||
let dest = app.paths.install_dir.join(name);
|
||||
copy_tree(&src, &dest)?;
|
||||
}
|
||||
// Restore the shims.
|
||||
let bin_src = src_root.join("bin");
|
||||
if bin_src.is_dir() {
|
||||
copy_tree(&bin_src, &app.paths.bin_dir)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Enforce settings.backups_keep: delete the oldest backups beyond the cap.
|
||||
pub fn prune(app: &App) -> Result<()> {
|
||||
let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize;
|
||||
let all = list_backups(app);
|
||||
if all.len() <= keep {
|
||||
return Ok(());
|
||||
}
|
||||
for old in all.iter().take(all.len() - keep) {
|
||||
let dir = backups_dir(app).join(&old.id);
|
||||
if dir.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively copy a directory tree (files and directories only; symlinks
|
||||
/// are copied as-is when possible).
|
||||
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let meta = std::fs::metadata(src)?;
|
||||
if meta.is_file() {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(src, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(dest)?;
|
||||
let mut entries: Vec<_> = std::fs::read_dir(src)?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for child in entries {
|
||||
let name = child
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("invalid path {}", child.display()))?;
|
||||
copy_tree(&child, &dest.join(name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Cli;
|
||||
use crate::state::StateFile;
|
||||
use clap::Parser;
|
||||
use std::io::Write;
|
||||
|
||||
fn test_app(tag: &str) -> App {
|
||||
let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
let cli = Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
cfg.to_str().unwrap(),
|
||||
"--yes",
|
||||
"--no-color",
|
||||
]);
|
||||
let mut app = App::from_cli(cli).expect("app builds");
|
||||
// Point the state/install dirs into the temp dir so nothing touches
|
||||
// the real user data.
|
||||
let state_file = dir.join("state.json");
|
||||
let install = dir.join("agents");
|
||||
std::fs::create_dir_all(&install).unwrap();
|
||||
app.paths.state_file = state_file.clone();
|
||||
app.paths.install_dir = install.clone();
|
||||
app.paths.bin_dir = install.join("bin");
|
||||
app.state = crate::state::StateStore::new(state_file);
|
||||
app.config_origin = Some(cfg);
|
||||
// Fresh backups dir for this test run.
|
||||
let _ = std::fs::remove_dir_all(backups_dir(&app));
|
||||
app
|
||||
}
|
||||
|
||||
fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf {
|
||||
let dir = app.paths.install_dir.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("agent.txt");
|
||||
let mut h = std::fs::File::create(&f).unwrap();
|
||||
h.write_all(content.as_bytes()).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_create_list_restore_roundtrip() {
|
||||
let app = test_app("roundtrip");
|
||||
write_agent_file(&app, "alpha", "v1");
|
||||
write_agent_file(&app, "beta", "b1");
|
||||
// Simulate a managed entry for alpha.
|
||||
let mut sf = StateFile::default();
|
||||
sf.installed.insert(
|
||||
"alpha".to_string(),
|
||||
crate::state::InstalledEntry {
|
||||
name: "alpha".to_string(),
|
||||
version: Some("1.0.0".to_string()),
|
||||
method: "binary".to_string(),
|
||||
install_dir: app.paths.install_dir.join("alpha").display().to_string(),
|
||||
bins: vec![],
|
||||
run: "alpha".to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
},
|
||||
);
|
||||
app.state.save(&sf).unwrap();
|
||||
|
||||
let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap();
|
||||
assert!(info.state && info.config);
|
||||
|
||||
// Corrupt the live install + state.
|
||||
write_agent_file(&app, "alpha", "BROKEN");
|
||||
let mut bad = StateFile::default();
|
||||
bad.sessions_count = 999;
|
||||
app.state.save(&bad).unwrap();
|
||||
|
||||
restore_backup(&app, Some(&info.id)).unwrap();
|
||||
let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap();
|
||||
assert_eq!(text, "v1", "install dir restored");
|
||||
let sf = app.state.load().unwrap();
|
||||
assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(sf.sessions_count, 0, "state restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_point_prefix_and_latest() {
|
||||
let app = test_app("resolve");
|
||||
create_backup(&app, "update:a", &[]).unwrap();
|
||||
let info = resolve_point(&app, None).unwrap();
|
||||
assert_eq!(info.agents, Vec::<String>::new());
|
||||
let prefix = &info.id[..info.id.len().min(10)];
|
||||
let by_prefix = resolve_point(&app, Some(prefix)).unwrap();
|
||||
assert_eq!(by_prefix.id, info.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_only_backups_keep() {
|
||||
let mut app = test_app("prune");
|
||||
app.config.settings.backups_keep = Some(1);
|
||||
create_backup(&app, "update:one", &[]).unwrap();
|
||||
create_backup(&app, "update:two", &[]).unwrap();
|
||||
let all = list_backups(&app);
|
||||
assert_eq!(all.len(), 1, "only the newest backup survives");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -133,7 +133,8 @@ impl Catalog {
|
||||
|
||||
/// Score a keyword against one haystack field: 100 for a full hit,
|
||||
/// then subsequence and typo bonuses; 0 = no relation at all.
|
||||
fn score_keyword(keyword: &str, hay: &str) -> u32 {
|
||||
/// Public so `am suggest` can reuse the exact same ranking (issue #61).
|
||||
pub fn score_keyword(keyword: &str, hay: &str) -> u32 {
|
||||
if keyword.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Remote catalogs (issues #60 #67, axes 7 & 9): fetching a catalog from
|
||||
//! an http(s) URL, validating its format, and caching it on disk the
|
||||
//! probe.rs way (keyed by URL + timestamp, TTL-bounded). Used both by
|
||||
//! 'am catalog update/add' and by config `include:` entries pointing at
|
||||
//! URLs.
|
||||
//!
|
||||
//! Rules:
|
||||
//! * only http, https and file:// URLs are accepted (download::validate_url);
|
||||
//! * a cached copy is served while it is fresh; on network failure the
|
||||
//! stale cache is used with a warning (offline support);
|
||||
//! * a malformed catalog is an error, never silently skipped.
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Default time-to-live of a cached remote catalog, in seconds (1 h).
|
||||
pub const DEFAULT_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Base URL of the official catalog (the agent-manager repo on Gitea).
|
||||
pub const DEFAULT_CATALOG_URL: &str =
|
||||
"https://git.dracodev.net/Projets/agent-manager/raw/branch/main/config.yaml";
|
||||
|
||||
/// One cached catalog: the raw text, the URL it came from, and the fetch
|
||||
/// time (seconds since the epoch).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedCatalog {
|
||||
pub url: String,
|
||||
pub fetched_at: u64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct CatalogCache {
|
||||
pub entries: BTreeMap<String, CachedCatalog>,
|
||||
}
|
||||
|
||||
impl CatalogCache {
|
||||
fn load(state_dir: &Path) -> Self {
|
||||
std::fs::read_to_string(state_dir.join("catalog-cache.json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(&self, state_dir: &Path) {
|
||||
if let Ok(text) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(state_dir.join("catalog-cache.json"), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the text of a catalog URL, using and refreshing the on-disk cache.
|
||||
/// `force` bypasses the TTL (used by 'am catalog update').
|
||||
pub fn fetch(app: &crate::app::App, url: &str, force: bool) -> Result<String> {
|
||||
let ttl = app
|
||||
.config
|
||||
.settings
|
||||
.catalog_cache_ttl_secs
|
||||
.unwrap_or(DEFAULT_TTL_SECS);
|
||||
fetch_with_cache(&app.events_dir(), url, force, ttl, Some(&app.log))
|
||||
}
|
||||
|
||||
/// Fetch a catalog URL during config loading (no App available yet): the
|
||||
/// cache lives in the state directory and the TTL is the default one.
|
||||
pub fn fetch_for_config(state_dir: &Path, url: &str) -> Result<String> {
|
||||
fetch_with_cache(state_dir, url, false, DEFAULT_TTL_SECS, None)
|
||||
}
|
||||
|
||||
/// Shared fetch + cache logic.
|
||||
fn fetch_with_cache(
|
||||
state_dir: &Path,
|
||||
url: &str,
|
||||
force: bool,
|
||||
ttl: u64,
|
||||
log: Option<&crate::output::Logger>,
|
||||
) -> Result<String> {
|
||||
crate::download::validate_url(url)?;
|
||||
let mut cache = CatalogCache::load(state_dir);
|
||||
let now = now_secs();
|
||||
|
||||
// Fresh cache hit: no network.
|
||||
if !force {
|
||||
if let Some(entry) = cache.entries.get(url) {
|
||||
if now.saturating_sub(entry.fetched_at) < ttl {
|
||||
return Ok(entry.text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try the network; fall back to the stale cache on failure.
|
||||
let fresh = fetch_url_text(url);
|
||||
match fresh {
|
||||
Ok(text) => {
|
||||
cache.entries.insert(
|
||||
url.to_string(),
|
||||
CachedCatalog {
|
||||
url: url.to_string(),
|
||||
fetched_at: now,
|
||||
text: text.clone(),
|
||||
},
|
||||
);
|
||||
cache.save(state_dir);
|
||||
Ok(text)
|
||||
}
|
||||
Err(net_err) => {
|
||||
if let Some(entry) = cache.entries.get(url) {
|
||||
if let Some(log) = log {
|
||||
log.warn(&format!(
|
||||
"cannot refresh catalog {url} ({net_err:#}) — using the cached copy from {}",
|
||||
format_ts(entry.fetched_at)
|
||||
));
|
||||
}
|
||||
Ok(entry.text.clone())
|
||||
} else {
|
||||
Err(net_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the raw text of an http(s) or file:// URL (no cache).
|
||||
fn fetch_url_text(url: &str) -> Result<String> {
|
||||
if let Some(local) = url.strip_prefix("file://") {
|
||||
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
|
||||
let local = if cfg!(windows) && local.starts_with('/') {
|
||||
local.strip_prefix('/').unwrap_or(local)
|
||||
} else {
|
||||
local
|
||||
};
|
||||
let p = PathBuf::from(local);
|
||||
return std::fs::read_to_string(&p)
|
||||
.with_context(|| format!("cannot read catalog file {}", p.display()));
|
||||
}
|
||||
let resp = ureq::get(url)
|
||||
.set("User-Agent", crate::download::USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.call()
|
||||
.map_err(|e| match e {
|
||||
ureq::Error::Status(code, r) => anyhow!(
|
||||
"HTTP {code} {} while fetching {url}",
|
||||
r.status_text()
|
||||
),
|
||||
other => anyhow!("cannot fetch {url}: {other}"),
|
||||
})?;
|
||||
resp.into_string()
|
||||
.with_context(|| format!("reading the catalog body of {url}"))
|
||||
}
|
||||
|
||||
/// Parse and validate a catalog YAML text; returns the parsed Config.
|
||||
pub fn parse_catalog(text: &str) -> Result<Config> {
|
||||
let cfg: Config = serde_yaml::from_str(text)
|
||||
.map_err(|e| anyhow!("the remote catalog is not a valid config: {e}"))?;
|
||||
let problems = config::validate(&cfg);
|
||||
if !problems.is_empty() {
|
||||
bail!(
|
||||
"the remote catalog is invalid:\n - {}",
|
||||
problems.join("\n - ")
|
||||
);
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Diff between the local effective catalog and a remote one: agent names
|
||||
/// that are new, removed, or changed (run command differs).
|
||||
pub struct CatalogDiff {
|
||||
pub added: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
pub changed: Vec<(String, String, String)>, // (name, local run, remote run)
|
||||
}
|
||||
|
||||
pub fn diff_catalogs(local: &Config, remote: &Config) -> CatalogDiff {
|
||||
let local_names: BTreeMap<&str, &str> = local
|
||||
.agents
|
||||
.iter()
|
||||
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
|
||||
.collect();
|
||||
let remote_names: BTreeMap<&str, &str> = remote
|
||||
.agents
|
||||
.iter()
|
||||
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
|
||||
.collect();
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
for (name, run) in &remote_names {
|
||||
match local_names.get(name) {
|
||||
None => added.push((*name).to_string()),
|
||||
Some(local_run) if local_run != run => {
|
||||
changed.push(((*name).to_string(), (*local_run).to_string(), (*run).to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for name in local_names.keys() {
|
||||
if !remote_names.contains_key(name) {
|
||||
removed.push((*name).to_string());
|
||||
}
|
||||
}
|
||||
added.sort();
|
||||
removed.sort();
|
||||
changed.sort();
|
||||
CatalogDiff {
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// The official catalog URL: the configured value, or the Gitea default.
|
||||
pub fn official_url(app: &crate::app::App) -> String {
|
||||
app.config
|
||||
.settings
|
||||
.catalog_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn format_ts(secs: u64) -> String {
|
||||
let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
|
||||
dt.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "unknown date".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = "version: \"1.0\"\nagents:\n - name: alpha\n run: alpha\n install: { type: binary, url: \"https://x/a\" }\n";
|
||||
|
||||
#[test]
|
||||
fn parses_and_validates_a_remote_catalog() {
|
||||
let cfg = parse_catalog(VALID).unwrap();
|
||||
assert_eq!(cfg.agents.len(), 1);
|
||||
assert_eq!(cfg.agents[0].name, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_catalogs() {
|
||||
assert!(parse_catalog("not: [valid").is_err());
|
||||
// Missing run on an installable agent is invalid.
|
||||
assert!(parse_catalog(
|
||||
"version: \"1.0\"\nagents:\n - name: x\n install: { type: npm, package: y }\n"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_detects_added_removed_changed() {
|
||||
let local: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: gone, run: gone, install: { type: binary, url: \"https://x/g\" } }\n - { name: tweaked, run: old, install: { type: binary, url: \"https://x/t\" } }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let remote: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: fresh, run: fresh, install: { type: binary, url: \"https://x/f\" } }\n - { name: tweaked, run: new, install: { type: binary, url: \"https://x/t\" } }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let d = diff_catalogs(&local, &remote);
|
||||
assert_eq!(d.added, vec!["fresh".to_string()]);
|
||||
assert_eq!(d.removed, vec!["gone".to_string()]);
|
||||
assert_eq!(d.changed.len(), 1);
|
||||
assert_eq!(d.changed[0].0, "tweaked");
|
||||
assert_eq!(d.changed[0].1, "old");
|
||||
assert_eq!(d.changed[0].2, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_fetch_works_offline() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("cat.yaml");
|
||||
std::fs::write(&p, VALID).unwrap();
|
||||
let url = format!("file://{}", p.display());
|
||||
let text = fetch_url_text(&url).unwrap();
|
||||
assert!(text.contains("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cache = CatalogCache::default();
|
||||
cache.entries.insert(
|
||||
"https://x".to_string(),
|
||||
CachedCatalog {
|
||||
url: "https://x".to_string(),
|
||||
fetched_at: 42,
|
||||
text: "body".to_string(),
|
||||
},
|
||||
);
|
||||
cache.save(dir.path());
|
||||
let back = CatalogCache::load(dir.path());
|
||||
assert_eq!(back.entries["https://x"].text, "body");
|
||||
}
|
||||
}
|
||||
+448
-6
@@ -54,6 +54,10 @@ pub struct Cli {
|
||||
#[arg(long, global = true, value_name = "THEME")]
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Output language: fr (default) or en (issue #74)
|
||||
#[arg(long, global = true, value_enum, value_name = "LANG")]
|
||||
pub lang: Option<crate::i18n::LangArg>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
@@ -157,6 +161,11 @@ pub enum Command {
|
||||
#[arg(long, value_name = "DIR")]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Set or show the active color theme (persisted for the next run)
|
||||
Theme {
|
||||
/// Theme name (omit to list available themes)
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Internal: list agent names for shell completions (hidden)
|
||||
#[command(name = "_agents", hide = true)]
|
||||
Agents {
|
||||
@@ -170,6 +179,50 @@ pub enum Command {
|
||||
/// Manage secrets in the OS keyring (never in plaintext config)
|
||||
#[command(subcommand)]
|
||||
Secret(SecretCmd),
|
||||
/// Audit the configuration: who changed what, when (issue #63)
|
||||
Audit,
|
||||
/// Register an agent as a system service (issue #55)
|
||||
#[command(subcommand)]
|
||||
Service(ServiceCmd),
|
||||
/// Schedule am commands (issue #56)
|
||||
#[command(subcommand)]
|
||||
Schedule(ScheduleCmd),
|
||||
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
Models(ModelsArgs),
|
||||
/// Community registry (issue #77): publish, search and install agent
|
||||
/// catalogs hosted on Gitea.
|
||||
Registry(RegistryArgs),
|
||||
/// Manage remote catalogs: update the official one, add external ones
|
||||
#[command(subcommand)]
|
||||
Catalog(CatalogCmd),
|
||||
/// Manage the LLM provider registry: base URLs, models, default provider
|
||||
Providers(ProviderArgs),
|
||||
/// Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
Suggest {
|
||||
/// Free-form request ("un agent pour du Python")
|
||||
#[arg(value_name = "QUERY", num_args = 1.., required = true)]
|
||||
words: Vec<String>,
|
||||
},
|
||||
/// Ask a natural-language request and get the matching am command(s)
|
||||
/// (issue #78): local rules first, optional LLM refinement, confirmation
|
||||
/// before execution.
|
||||
Ask {
|
||||
/// The request in natural language
|
||||
#[arg(value_name = "REQUEST", num_args = 1.., required = true)]
|
||||
query: Vec<String>,
|
||||
/// Skip the confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||
#[command(alias = "shell")]
|
||||
Ai(AiArgs),
|
||||
/// Onboarding wizard: provider, token, modèle par défaut, aichat, rôles copilot (épique v1.1.0)
|
||||
Setup {
|
||||
/// Only (re)generate the am-* copilot roles for aichat
|
||||
#[arg(long)]
|
||||
roles: bool,
|
||||
},
|
||||
/// Start an agent (foreground by default, or detached with --background)
|
||||
Start(StartArgs),
|
||||
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
|
||||
@@ -215,6 +268,15 @@ pub enum Command {
|
||||
/// Resume a finished session: relaunch the agent with its recorded arguments
|
||||
#[arg(long, value_name = "ID")]
|
||||
resume: Option<String>,
|
||||
/// Export one session (metadata, commands, log excerpt) to a JSON file
|
||||
#[arg(long, value_name = "ID")]
|
||||
export: Option<String>,
|
||||
/// Destination of the export (default: ./am-session-<ID>.json)
|
||||
#[arg(long, value_name = "FILE")]
|
||||
output: Option<String>,
|
||||
/// Purge sessions finished more than N days ago (settings default 90)
|
||||
#[arg(long, value_name = "DAYS")]
|
||||
retention: Option<u64>,
|
||||
},
|
||||
/// Show usage statistics computed from the event journal
|
||||
Stats {
|
||||
@@ -223,6 +285,40 @@ pub enum Command {
|
||||
/// Only events of the last period (7d, 30d, 90d, all)
|
||||
#[arg(long, value_name = "PERIOD")]
|
||||
period: Option<String>,
|
||||
/// Show token usage and estimated spend per agent (issue #49)
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
costs: bool,
|
||||
},
|
||||
/// Real-time monitor of the managed processes (issue #50)
|
||||
Monitor {
|
||||
/// Refresh interval in seconds (default 2)
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
interval: Option<u64>,
|
||||
/// Print one JSON document per tick instead of the TUI
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
json: bool,
|
||||
},
|
||||
/// Local read-only web dashboard with charts (issue #54)
|
||||
Web(WebArgs),
|
||||
/// Authenticated HTTP + WebSocket API to drive am remotely (issue #80)
|
||||
Serve(ServeArgs),
|
||||
/// Push the state into the configured git repository (issue #66)
|
||||
Sync {
|
||||
/// Commit message (default: "am sync — state update")
|
||||
#[arg(long, value_name = "MESSAGE")]
|
||||
message: Option<String>,
|
||||
},
|
||||
/// Transfer the installation and state to another machine (issue #68)
|
||||
Migrate {
|
||||
/// Export the bundle (default) or import one
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
export: bool,
|
||||
/// Bundle path for export (default agent-manager-migrate.amx)
|
||||
#[arg(long, value_name = "FILE")]
|
||||
output: Option<PathBuf>,
|
||||
/// Bundle path to import
|
||||
#[arg(value_name = "BUNDLE")]
|
||||
bundle: Option<PathBuf>,
|
||||
},
|
||||
/// Show the most used agents (top 10)
|
||||
Top {
|
||||
@@ -288,6 +384,15 @@ pub enum Command {
|
||||
/// Reinstall even if already installed
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Provider to configure (default: the agent's provider, else settings.default_provider) (issue #90)
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
/// Model to configure (default: the provider's default model) (issue #90)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Skip the post-install provider configuration (issue #91)
|
||||
#[arg(long)]
|
||||
no_config: bool,
|
||||
},
|
||||
/// Uninstall an agent (managed or external) and clean its files
|
||||
Uninstall {
|
||||
@@ -304,6 +409,10 @@ pub enum Command {
|
||||
/// Update every installed managed agent
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
/// Roll back to a pre-update backup instead of updating
|
||||
/// ("latest" or a backup id; "--rollback list" shows them)
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "latest")]
|
||||
rollback: Option<String>,
|
||||
},
|
||||
/// Search the catalog by keyword (name, description, category, tags)
|
||||
Search {
|
||||
@@ -340,7 +449,26 @@ pub enum Command {
|
||||
/// Re-run the Nth most recent entry (with confirmation)
|
||||
#[arg(long, value_name = "N")]
|
||||
rerun: Option<usize>,
|
||||
/// Range to export as a playbook: "12..25" (1-based, newest first)
|
||||
#[arg(value_name = "RANGE")]
|
||||
range: Option<String>,
|
||||
/// Export the range as a playbook YAML file (issue #51)
|
||||
#[arg(long, value_name = "FILE")]
|
||||
save: Option<PathBuf>,
|
||||
},
|
||||
/// Replay a saved playbook step by step with confirmation (issue #51)
|
||||
Playbook {
|
||||
/// Playbook file (state_dir/playbooks/ when a bare name is given)
|
||||
#[arg(value_name = "FILE")]
|
||||
path: PathBuf,
|
||||
/// Variable values, repeatable: --var name=value
|
||||
#[arg(long, value_name = "NAME=VALUE")]
|
||||
var: Vec<String>,
|
||||
},
|
||||
/// Benchmark: run the same task on several agents and compare (issue #62)
|
||||
Lab(LabArgs),
|
||||
/// List the event plugins and test one (issue #75)
|
||||
Plugins(PluginsArgs),
|
||||
/// Show detailed information about one agent
|
||||
Info {
|
||||
/// Agent name or alias
|
||||
@@ -351,6 +479,10 @@ pub enum Command {
|
||||
/// Overwrite an existing agent-manager.yaml
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Use a template (web, python, rust, cli) instead of stack detection;
|
||||
/// "list" shows the available templates
|
||||
#[arg(long, value_name = "TEMPLATE")]
|
||||
template: Option<String>,
|
||||
},
|
||||
/// Display help: the overview, a command, the command list, or a search
|
||||
Help {
|
||||
@@ -362,6 +494,13 @@ pub enum Command {
|
||||
},
|
||||
/// Display version and build information
|
||||
Version,
|
||||
/// Cheat sheet: the most useful commands, their key options, your
|
||||
/// most-used commands and a rotating tip of the day
|
||||
Tip {
|
||||
/// Show a single random tip instead of the whole page
|
||||
#[arg(long)]
|
||||
random: bool,
|
||||
},
|
||||
/// Manage the configuration file
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCmd),
|
||||
@@ -370,11 +509,26 @@ pub enum Command {
|
||||
/// Attempt to repair problems (create directories, fix the state file)
|
||||
#[arg(long)]
|
||||
fix: bool,
|
||||
/// Re-run the checks every N seconds, alerting on failure (issue #59)
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
watch: Option<u64>,
|
||||
},
|
||||
/// Run the agent command directly with the given arguments (no process management)
|
||||
Run {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
/// Local model to use for this run (validated against the local runtimes)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
/// Skip the agent's sandbox profile (issue #79)
|
||||
#[arg(long)]
|
||||
no_sandbox: bool,
|
||||
/// Run the agent inside a container (issue #58)
|
||||
#[arg(long)]
|
||||
container: bool,
|
||||
/// Arguments passed through to the agent command
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
|
||||
args: Vec<std::ffi::OsString>,
|
||||
@@ -440,6 +594,241 @@ pub struct StartArgs {
|
||||
/// Apply an environment profile (env + args, defined in the config)
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub profile: Option<String>,
|
||||
/// Local model to use for this run (validated against the local runtimes)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
pub model: Option<String>,
|
||||
/// Provider to use for this run (issue #92): resolved from the registry
|
||||
#[arg(long)]
|
||||
pub provider: Option<String>,
|
||||
/// Skip the agent's sandbox profile for this run (issue #79)
|
||||
#[arg(long)]
|
||||
pub no_sandbox: bool,
|
||||
/// Start every member of a group simultaneously (issue #57)
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub parallel: bool,
|
||||
/// Run the agent inside a container (issue #58)
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub container: bool,
|
||||
}
|
||||
|
||||
/// Arguments of the models command: inventory by default, prune with --prune.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct ModelsArgs {
|
||||
/// Purge unused models instead of listing them
|
||||
#[arg(long)]
|
||||
pub prune: bool,
|
||||
/// Only list what would be pruned, delete nothing
|
||||
#[arg(long, conflicts_with = "yes")]
|
||||
pub dry_run: bool,
|
||||
/// Answer yes to the deletion prompt
|
||||
#[arg(long)]
|
||||
pub yes: bool,
|
||||
/// Consider a model unused after N days (default: 30)
|
||||
#[arg(long, value_name = "DAYS")]
|
||||
pub days: Option<u64>,
|
||||
}
|
||||
|
||||
/// Arguments of the lab command (issue #62).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct LabArgs {
|
||||
/// Comma-separated agent names (or aliases) to benchmark
|
||||
#[arg(long, value_name = "AGENTS", required_unless_present = "list")]
|
||||
pub agents: Option<String>,
|
||||
/// Task file (a bare name resolves under state_dir/lab/, .yaml appended)
|
||||
#[arg(long, value_name = "FILE", required_unless_present = "list")]
|
||||
pub task: Option<PathBuf>,
|
||||
/// Run every agent concurrently instead of one after another
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub parallel: bool,
|
||||
/// Override the task timeout in seconds
|
||||
#[arg(long, value_name = "SECS")]
|
||||
pub timeout: Option<u64>,
|
||||
/// List the task files of the lab directory
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub list: bool,
|
||||
}
|
||||
|
||||
/// Arguments of the registry command (issue #77).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct RegistryArgs {
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<RegistryCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum RegistryCmd {
|
||||
/// Prepare a catalog + manifest (source, version, author, sha256) for
|
||||
/// publication on Gitea
|
||||
Publish {
|
||||
/// Path of the catalog YAML file
|
||||
catalog: PathBuf,
|
||||
/// Public URL of the catalog once pushed (recorded in the manifest)
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
/// Author name recorded in the manifest
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
/// Catalog version (default 1.0.0)
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
},
|
||||
/// Search agents across the registered sources (settings.registry.sources)
|
||||
Search {
|
||||
/// Free-form keyword (agent name, description or tag)
|
||||
query: String,
|
||||
},
|
||||
/// Install a catalog from the registry: manifest + checksum validation,
|
||||
/// then an explicit trust decision for unknown sources
|
||||
Install {
|
||||
/// Catalog URL
|
||||
url: String,
|
||||
/// Skip the trust confirmation
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// List the registered sources
|
||||
List,
|
||||
}
|
||||
|
||||
/// Arguments of the plugins command (issue #75).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct PluginsArgs {
|
||||
/// Run one plugin against a synthetic event and print its response
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub test: Option<String>,
|
||||
}
|
||||
|
||||
/// Arguments of the web command (issue #54).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct WebArgs {
|
||||
/// Port to listen on (default 7878)
|
||||
#[arg(long, value_name = "PORT")]
|
||||
pub port: Option<u16>,
|
||||
/// Do not open the browser automatically
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub no_open: bool,
|
||||
}
|
||||
|
||||
/// Arguments of am ai (issues #96 #97): natural language → shell action
|
||||
/// via AIChat. The global flags --dry-run and --yes/-y apply as well.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct AiArgs {
|
||||
/// The request in natural language
|
||||
#[arg(value_name = "PROMPT", num_args = 1.., required = true)]
|
||||
pub prompt: Vec<String>,
|
||||
/// Execute the generated shell command (after the safety policy)
|
||||
#[arg(short = 'e', long, action = ArgAction::SetTrue)]
|
||||
pub exec: bool,
|
||||
/// Files or directories passed as context (repeatable; default: the
|
||||
/// current directory)
|
||||
#[arg(short = 'f', long = "files", value_name = "PATH", action = ArgAction::Append)]
|
||||
pub files: Vec<std::path::PathBuf>,
|
||||
/// Provider of the registry used by aichat (env vars + model)
|
||||
#[arg(long, value_name = "ID")]
|
||||
pub provider: Option<String>,
|
||||
/// Force a model (aichat --model)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
pub model: Option<String>,
|
||||
/// Use one of the generated copilot roles (am-copilot, am-operator,
|
||||
/// am-dev, am-do, am-analyst, am-orchestrator — issue v1.1.0 F3)
|
||||
#[arg(long, value_name = "ROLE")]
|
||||
pub role: Option<String>,
|
||||
/// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is
|
||||
/// handled by clap directly).
|
||||
#[arg(skip)]
|
||||
pub yes: bool,
|
||||
/// REPL-only: --dry-run given on the REPL line.
|
||||
#[arg(skip)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
/// Arguments of am serve (issue #80).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct ServeArgs {
|
||||
/// Bearer token required on every request (mandatory)
|
||||
#[arg(long, value_name = "TOKEN")]
|
||||
pub token: Option<String>,
|
||||
/// Listen address (default 127.0.0.1)
|
||||
#[arg(long, value_name = "HOST")]
|
||||
pub host: Option<String>,
|
||||
/// Listening port (default 8080)
|
||||
#[arg(long, value_name = "PORT")]
|
||||
pub port: Option<u16>,
|
||||
/// Per-IP request budget per minute (default 120)
|
||||
#[arg(long, value_name = "N")]
|
||||
pub rate_limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Remote catalog subcommands (issues #60 #67).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum CatalogCmd {
|
||||
/// Fetch the official catalog, show the diff and register it as an include
|
||||
Update,
|
||||
/// Register an external catalog (same YAML format) by URL
|
||||
Add {
|
||||
/// Catalog URL (http, https or file)
|
||||
#[arg(value_name = "URL")]
|
||||
url: String,
|
||||
},
|
||||
/// List the registered remote catalogs
|
||||
List,
|
||||
}
|
||||
|
||||
/// LLM provider registry subcommands (issue #88). The subcommand is
|
||||
/// optional: `am providers` alone lists the registry.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct ProviderArgs {
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<ProvidersCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ProvidersCmd {
|
||||
/// List the registered providers (the default one is starred)
|
||||
List,
|
||||
/// Show one provider in detail (base URL, models, default model)
|
||||
Show {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Register a provider, or update an existing one
|
||||
Add {
|
||||
/// Provider name (lowercase slug, e.g. openai)
|
||||
name: String,
|
||||
/// Base URL of the provider API
|
||||
#[arg(long, value_name = "URL", required = true)]
|
||||
base_url: String,
|
||||
/// Model used by default for this provider
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Comma-separated model list
|
||||
#[arg(long, value_name = "MODELS")]
|
||||
models: Option<String>,
|
||||
},
|
||||
/// Remove a provider from the registry
|
||||
Remove {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Store the provider API token in the OS keyring (shared by every
|
||||
/// agent of this provider through the @secret cascade, issue #89)
|
||||
SetToken {
|
||||
/// Provider name
|
||||
name: String,
|
||||
/// Token value (never logged)
|
||||
#[arg(long, value_name = "VALUE")]
|
||||
value: String,
|
||||
},
|
||||
/// Check whether a provider token exists (the value is never shown)
|
||||
Token {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Set the default provider
|
||||
Default {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Environment profile subcommands (issue #40).
|
||||
@@ -454,16 +843,66 @@ pub enum ProfileCmd {
|
||||
},
|
||||
}
|
||||
|
||||
/// Secret management subcommands.
|
||||
/// 'am service' subcommands (issue #55).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ServiceCmd {
|
||||
/// Install the service unit (systemd / launchd / Task Scheduler)
|
||||
Install {
|
||||
/// Agent name
|
||||
agent: String,
|
||||
/// Enable autostart (boot/logon)
|
||||
#[arg(long)]
|
||||
autostart: bool,
|
||||
},
|
||||
/// Remove the service unit
|
||||
Uninstall {
|
||||
/// Agent name
|
||||
agent: String,
|
||||
},
|
||||
/// Show whether the service is installed
|
||||
Status {
|
||||
/// Agent name
|
||||
agent: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 'am schedule' subcommands (issue #56).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ScheduleCmd {
|
||||
/// Add a daily schedule (ex: 'am schedule add update --all')
|
||||
Add {
|
||||
/// Command to run, e.g. ["update", "--all"]
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "CMD...")]
|
||||
command: Vec<String>,
|
||||
/// Time of day HH:MM (default 08:00)
|
||||
#[arg(long, value_name = "HH:MM")]
|
||||
at: Option<String>,
|
||||
},
|
||||
/// List the schedules
|
||||
List,
|
||||
/// Remove a schedule by id
|
||||
Remove {
|
||||
/// Schedule id ('am schedule list' shows them)
|
||||
id: String,
|
||||
},
|
||||
/// Run a schedule now (invoked by the OS task, issue #56)
|
||||
Run {
|
||||
/// Schedule id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum SecretCmd {
|
||||
/// Store a secret for an agent
|
||||
/// Store a secret for an agent (or a provider, issue #89)
|
||||
Set {
|
||||
/// Secret name (the environment variable name)
|
||||
name: String,
|
||||
/// Agent it belongs to
|
||||
/// Agent it belongs to (required unless --provider is given)
|
||||
#[arg(long, value_name = "AGENT")]
|
||||
agent: String,
|
||||
agent: Option<String>,
|
||||
/// Provider it belongs to (shared by every agent of this provider)
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
/// Secret value (prefer --value over shell history; never logged)
|
||||
#[arg(long, value_name = "VALUE")]
|
||||
value: String,
|
||||
@@ -472,9 +911,12 @@ pub enum SecretCmd {
|
||||
Unset {
|
||||
/// Secret name
|
||||
name: String,
|
||||
/// Agent it belongs to
|
||||
/// Agent it belongs to (required unless --provider is given)
|
||||
#[arg(long, value_name = "AGENT")]
|
||||
agent: String,
|
||||
agent: Option<String>,
|
||||
/// Provider it belongs to
|
||||
#[arg(long, value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
},
|
||||
/// List stored secret names (values are never shown)
|
||||
List,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! launched: installed agents (managed + external), aliases and groups.
|
||||
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// The completion pool: installed agents (managed + external), aliases and
|
||||
/// groups when installed is true, the whole catalog otherwise.
|
||||
@@ -10,16 +11,29 @@ pub fn agent_names(app: &App, installed: bool) -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
if installed {
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
let mut launchable: HashSet<String> = HashSet::new();
|
||||
for a in app.catalog.agents() {
|
||||
if app.state.get(&a.name).ok().flatten().is_some()
|
||||
|| probe.path_of(&a.name).is_some()
|
||||
{
|
||||
launchable.insert(a.name.clone());
|
||||
names.push(a.name.clone());
|
||||
}
|
||||
}
|
||||
names.extend(app.config.aliases.keys().cloned());
|
||||
for g in app.config.groups.keys() {
|
||||
names.push(format!("group:{g}"));
|
||||
for (alias, target) in &app.config.aliases {
|
||||
let is_launchable = app
|
||||
.catalog
|
||||
.resolve(alias)
|
||||
.map(|agent| launchable.contains(&agent.name))
|
||||
.unwrap_or_else(|| launchable.contains(target));
|
||||
if is_launchable {
|
||||
names.push(alias.clone());
|
||||
}
|
||||
}
|
||||
for (group, members) in &app.config.groups {
|
||||
if members.iter().any(|m| launchable.contains(m)) {
|
||||
names.push(format!("group:{group}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
names.extend(app.catalog.agents().iter().map(|a| a.name.clone()));
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! am ai — langage naturel → action shell (issues #96 #97).
|
||||
//!
|
||||
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
||||
//! context files (default: the current directory). Exec mode generates the
|
||||
//! shell command through aichat in dry-run, classifies it, applies the
|
||||
//! safety policy (settings.shell_ai, dry-run by default) and only executes
|
||||
//! after confirmation when required.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::AiArgs;
|
||||
use crate::commands::{require_agent, resolve_exec};
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::shell_ai::{Decision, SafetyMode};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
||||
// Onboarding hint (v1.1.0 F1) — non-blocking: the AI commands propose
|
||||
// `am setup` when no provider is configured, then continue anyway.
|
||||
if crate::setup::needs_setup(app) {
|
||||
app.log.info(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"am n'est pas configuré — lancez am setup (provider + token)",
|
||||
));
|
||||
}
|
||||
|
||||
// F2 (v1.1.0): aichat must be INSTALLED (not only in the catalog) to
|
||||
// power am ai. Offer the installation when missing.
|
||||
if !aichat_installed(app) {
|
||||
let ask_install = !app.cli.yes
|
||||
&& app.confirm(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"aichat est manquant. Installer ?",
|
||||
))?;
|
||||
if app.cli.yes || ask_install {
|
||||
app.log.info(crate::i18n::tr_in(app.lang(), "Installation d'aichat…"));
|
||||
crate::commands::install_cmd::run(app, "aichat", None, false, None, None, false)?;
|
||||
} else {
|
||||
bail!(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"installez aichat: am install aichat — puis réessayez"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let prompt = args.prompt.join(" ");
|
||||
// Context files: explicit --files wins, else the current directory
|
||||
// (issue #96: `aichat -f . "<prompt>"`).
|
||||
let files: Vec<String> = if args.files.is_empty() {
|
||||
vec![".".to_string()]
|
||||
} else {
|
||||
args.files
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// REPL per-line flags combine with the CLI globals.
|
||||
let yes = app.cli.yes || args.yes;
|
||||
let dry_run = app.cli.dry_run || args.dry_run;
|
||||
|
||||
let agent = require_agent(app, "aichat")?;
|
||||
|
||||
if !args.exec {
|
||||
return chat_mode(app, agent, &prompt, &files, args, dry_run);
|
||||
}
|
||||
exec_mode(app, &prompt, &files, args, yes, dry_run)
|
||||
}
|
||||
|
||||
/// True when the aichat binary is available (installed or on PATH).
|
||||
/// Uses a direct PATH lookup — the probe cache would mask a shim added
|
||||
/// mid-test and is meant for agent inventories, not runtime checks.
|
||||
fn aichat_installed(app: &App) -> bool {
|
||||
app.state.get("aichat").ok().flatten().is_some()
|
||||
|| which::which("aichat").is_ok()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// file 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");
|
||||
let dir = crate::roles::aichat_agents_dir();
|
||||
let path = dir.join(format!("{role}.md"));
|
||||
if path.exists() {
|
||||
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 path.exists() {
|
||||
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"
|
||||
));
|
||||
}
|
||||
let role = role_args(app, args.role.as_deref())?;
|
||||
let cmd = crate::shell_ai::generate(
|
||||
app,
|
||||
prompt,
|
||||
files,
|
||||
args.provider.as_deref(),
|
||||
args.model.as_deref(),
|
||||
&role,
|
||||
)?;
|
||||
let settings = app
|
||||
.config
|
||||
.settings
|
||||
.shell_ai
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns);
|
||||
let certainty = crate::shell_ai::estimate_certainty(&cmd, risk);
|
||||
let mode = settings.safety_mode();
|
||||
|
||||
// Show the generated command and its classification (issue #97 UX).
|
||||
println!("🔒 Commande générée : {}", cmd);
|
||||
println!(" Risk : {}", risk.as_str());
|
||||
println!(" Certainty: {}%", certainty);
|
||||
println!(
|
||||
" Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)",
|
||||
mode.as_str()
|
||||
);
|
||||
|
||||
let decision = crate::shell_ai::decide(mode, risk, dry_run, yes);
|
||||
let lang = app.lang();
|
||||
let mut executed = false;
|
||||
match decision {
|
||||
Decision::Abort => {
|
||||
let msg = if dry_run {
|
||||
crate::i18n::tr_in(lang, "dry-run : commande non exécutée")
|
||||
} else {
|
||||
crate::i18n::tr_in(
|
||||
lang,
|
||||
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes",
|
||||
)
|
||||
};
|
||||
app.log.info(msg);
|
||||
}
|
||||
Decision::Ask => {
|
||||
let ok = app.confirm(crate::i18n::tr_in(lang, "Exécuter ?"))?;
|
||||
if ok {
|
||||
executed = true;
|
||||
} else {
|
||||
app.log.info(crate::i18n::tr_in(lang, "annulé"));
|
||||
}
|
||||
}
|
||||
Decision::Execute => {
|
||||
executed = true;
|
||||
}
|
||||
}
|
||||
let code = if executed {
|
||||
app.log.info(&format!("exécution: {}", cmd));
|
||||
let code = crate::shell_ai::execute(app, &cmd)?;
|
||||
code
|
||||
} else {
|
||||
0
|
||||
};
|
||||
app.emit(
|
||||
&Event::now(EventKind::ShellAi)
|
||||
.with_agent("aichat".to_string())
|
||||
.with_args(vec![
|
||||
"mode=exec".to_string(),
|
||||
format!("risk={}", risk.as_str()),
|
||||
format!("certainty={certainty}"),
|
||||
format!("executed={executed}"),
|
||||
]),
|
||||
);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decision_dry_run_is_the_default_policy() {
|
||||
// The embedded config does not set shell_ai -> dry-run default.
|
||||
let s = crate::config::ShellAiSettings::default();
|
||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! audit: who changed what, when (issue #63, axe 8). Config checksums are
|
||||
//! recorded in state.json; 'am audit' compares them with the current files,
|
||||
//! cross-references the event journal to date the changes, and flags edits
|
||||
//! made outside of agent-manager.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::print_json;
|
||||
use crate::state::ConfigChecksum;
|
||||
use anyhow::Result;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Labels of the tracked config files.
|
||||
const EMBEDDED: &str = "embedded";
|
||||
const USER: &str = "user";
|
||||
const LOCAL: &str = "local";
|
||||
|
||||
/// SHA-256 (hex) of a file's content, plus its mtime.
|
||||
fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(text.as_bytes());
|
||||
ConfigChecksum {
|
||||
sha256: format!("{:x}", h.finalize()),
|
||||
mtime_secs,
|
||||
recorded_at: crate::installers::now_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
/// mtime in seconds since epoch.
|
||||
fn mtime_secs(path: &std::path::Path) -> Option<u64> {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
}
|
||||
|
||||
/// The tracked config files: label -> (display path, content).
|
||||
fn tracked_files(app: &App) -> Vec<(&'static str, String, Option<u64>)> {
|
||||
let mut out = Vec::new();
|
||||
// Embedded default catalog: content is baked into the binary.
|
||||
out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None));
|
||||
// User config file.
|
||||
if let Some(dir) = &app.paths.config_dir {
|
||||
let p = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||
out.push((USER, text, mtime_secs(&p)));
|
||||
}
|
||||
}
|
||||
// Local project config (agent-manager.yaml in the current directory).
|
||||
let local = PathBuf::from(crate::config::CONFIG_FILE_NAME);
|
||||
if let Ok(text) = std::fs::read_to_string(&local) {
|
||||
out.push((LOCAL, text, mtime_secs(&local)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Kinds of events that represent a modification of the configuration or
|
||||
/// the fleet (they explain a checksum change).
|
||||
fn is_mutating_kind(k: &EventKind) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
EventKind::Config
|
||||
| EventKind::Install
|
||||
| EventKind::Update
|
||||
| EventKind::Uninstall
|
||||
| EventKind::Annotate
|
||||
| EventKind::Catalog
|
||||
| EventKind::Model
|
||||
| EventKind::Backup
|
||||
| EventKind::Rollback
|
||||
)
|
||||
}
|
||||
|
||||
/// One audit row: a tracked file and its verdict.
|
||||
#[derive(serde::Serialize)]
|
||||
struct FileRow {
|
||||
file: String,
|
||||
label: &'static str,
|
||||
status: &'static str, // ok | new | changed | deleted
|
||||
changed_at: Option<String>,
|
||||
via: Option<String>, // "am <kind>" or "manual (outside am)"
|
||||
}
|
||||
|
||||
/// One audited journal event.
|
||||
#[derive(serde::Serialize)]
|
||||
struct EventRow {
|
||||
when: String,
|
||||
who: String, // "am <kind>"
|
||||
what: String,
|
||||
agent: Option<String>,
|
||||
}
|
||||
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
let mut sf = app.state.load()?;
|
||||
let baseline = std::mem::take(&mut sf.config_checksums);
|
||||
|
||||
// 1. Current checksums of every tracked file.
|
||||
let files = tracked_files(app);
|
||||
let mut rows: Vec<FileRow> = Vec::new();
|
||||
let mut next_baseline: BTreeMap<String, ConfigChecksum> = BTreeMap::new();
|
||||
|
||||
for (label, text, mtime) in &files {
|
||||
let current = checksum_of(text, mtime.unwrap_or(0));
|
||||
let (status, via, changed_at) = match baseline.get(*label) {
|
||||
None => ("new", Some("baseline recorded now".to_string()), None),
|
||||
Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None),
|
||||
Some(prev) => {
|
||||
let at = mtime
|
||||
.and_then(|t| {
|
||||
chrono::DateTime::from_timestamp(t as i64, 0)
|
||||
.map(|d| d.to_rfc3339())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
(status_changed(prev, ¤t, app, &at), Some(at.clone()), Some(at))
|
||||
}
|
||||
};
|
||||
let display = match *label {
|
||||
EMBEDDED => "config.yaml (embedded default)".to_string(),
|
||||
USER => user_config_display(app),
|
||||
LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME),
|
||||
_ => label.to_string(),
|
||||
};
|
||||
rows.push(FileRow {
|
||||
file: display,
|
||||
label,
|
||||
status,
|
||||
changed_at,
|
||||
via,
|
||||
});
|
||||
next_baseline.insert(label.to_string(), current);
|
||||
}
|
||||
|
||||
// 2. Cross-reference the journal: recent mutating events.
|
||||
let events = crate::events::read_events(&app.events_dir(), 200);
|
||||
let event_rows: Vec<EventRow> = events
|
||||
.iter()
|
||||
.filter(|e| is_mutating_kind(&e.kind))
|
||||
.map(|e| EventRow {
|
||||
when: e.ts.clone(),
|
||||
who: format!("am {}", e.kind.as_str()),
|
||||
what: format!(
|
||||
"{}{}",
|
||||
e.reason.clone().unwrap_or_default(),
|
||||
if e.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", e.args.join(", "))
|
||||
}
|
||||
),
|
||||
agent: e.agent.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Persist the new baseline so the next audit compares against now.
|
||||
sf.config_checksums = next_baseline;
|
||||
app.state.save(&sf)?;
|
||||
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"files": rows,
|
||||
"events": event_rows,
|
||||
"generated_at": crate::installers::now_rfc3339(),
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 4. Human-readable report.
|
||||
println!("config audit — who changed what, when\n");
|
||||
let mut header = crate::tables::DataTable {
|
||||
columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()],
|
||||
rows: Vec::new(),
|
||||
};
|
||||
for r in &rows {
|
||||
header.rows.push(vec![
|
||||
crate::tables::Cell::str(&r.file),
|
||||
crate::tables::Cell::str(status_style(r.status)),
|
||||
crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")),
|
||||
crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")),
|
||||
]);
|
||||
}
|
||||
let width = crate::output::terminal_width().unwrap_or(120);
|
||||
print!("{}", header.render_themed(app.theme(), app.color(), width));
|
||||
|
||||
println!("\nrecent changes (event journal):");
|
||||
if event_rows.is_empty() {
|
||||
println!(" none");
|
||||
} else {
|
||||
for e in event_rows.iter().take(15) {
|
||||
println!(
|
||||
" {} {} {}{}",
|
||||
e.when,
|
||||
e.who,
|
||||
e.agent.as_deref().unwrap_or("-"),
|
||||
if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = rows.iter().filter(|r| r.status == "changed").count();
|
||||
if changed == 0 {
|
||||
app.log.success("no configuration change detected since the last audit");
|
||||
} else {
|
||||
app.log
|
||||
.warn(&format!("{changed} configuration file(s) changed since the last audit"));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn status_changed(
|
||||
prev: &ConfigChecksum,
|
||||
current: &ConfigChecksum,
|
||||
app: &App,
|
||||
at: &str,
|
||||
) -> &'static str {
|
||||
// A change can be explained by am itself (an event after the baseline).
|
||||
let events = crate::events::read_events(&app.events_dir(), 100);
|
||||
let explained = events.iter().any(|e| {
|
||||
is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str()
|
||||
});
|
||||
// 'via' is filled by the caller from this: manual vs am.
|
||||
let _ = (current, at);
|
||||
if explained {
|
||||
"changed"
|
||||
} else {
|
||||
"manual"
|
||||
}
|
||||
}
|
||||
|
||||
fn status_style(s: &str) -> String {
|
||||
match s {
|
||||
"ok" => "ok".to_string(),
|
||||
"new" => "new".to_string(),
|
||||
"changed" => "CHANGED".to_string(),
|
||||
"manual" => "MANUAL".to_string(),
|
||||
_ => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_config_display(app: &App) -> String {
|
||||
app.paths
|
||||
.config_dir
|
||||
.as_ref()
|
||||
.map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string())
|
||||
.unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Cli;
|
||||
use crate::state::{StateFile, StateStore};
|
||||
use clap::Parser;
|
||||
use std::io::Write;
|
||||
|
||||
fn test_app(tag: &str) -> (App, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
let cli = Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
cfg.to_str().unwrap(),
|
||||
"--no-color",
|
||||
]);
|
||||
let mut app = App::from_cli(cli).expect("app builds");
|
||||
let state_file = dir.path().join("state.json");
|
||||
app.paths.state_file = state_file.clone();
|
||||
app.paths.config_dir = Some(dir.path().to_path_buf());
|
||||
app.state = StateStore::new(state_file);
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_changes_when_content_changes() {
|
||||
let a = checksum_of("version: \"1.0\"\n", 0);
|
||||
let b = checksum_of("version: \"1.0\"\n# extra\n", 0);
|
||||
assert_ne!(a.sha256, b.sha256, "different content -> different hash");
|
||||
assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_records_baseline_and_detects_change() {
|
||||
let (app, dir) = test_app("audit");
|
||||
let user_cfg = dir.path().join("config.yaml");
|
||||
|
||||
// First run: everything is recorded as baseline.
|
||||
run(&app).unwrap();
|
||||
let sf = app.state.load().unwrap();
|
||||
assert!(sf.config_checksums.contains_key(EMBEDDED));
|
||||
assert!(sf.config_checksums.contains_key(USER));
|
||||
|
||||
// Touch the user config outside of am.
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&user_cfg)
|
||||
.unwrap();
|
||||
writeln!(f, "# modified by hand").unwrap();
|
||||
drop(f);
|
||||
|
||||
// Second run: the change is flagged (status != ok).
|
||||
run(&app).unwrap();
|
||||
let sf2 = app.state.load().unwrap();
|
||||
// The baseline was re-recorded, so a third run is clean.
|
||||
run(&app).unwrap();
|
||||
let sf3 = app.state.load().unwrap();
|
||||
assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len());
|
||||
assert!(sf3.config_checksums[USER].sha256.len() == 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_file_roundtrips_checksums() {
|
||||
let mut sf = StateFile::default();
|
||||
sf.config_checksums.insert(
|
||||
"user".to_string(),
|
||||
ConfigChecksum {
|
||||
sha256: "ab".repeat(32),
|
||||
mtime_secs: 42,
|
||||
recorded_at: "2026-08-18T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
let text = serde_json::to_string(&sf).unwrap();
|
||||
let back: StateFile = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.config_checksums["user"].mtime_secs, 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9).
|
||||
//! 'am catalog update' refreshes the official catalog and applies it as an
|
||||
//! include; 'am catalog add <url>' registers any external catalog (same
|
||||
//! YAML format) the same way. Everything goes through the probe-style
|
||||
//! cache in catalog_remote so offline runs keep working.
|
||||
|
||||
use super::*;
|
||||
use crate::catalog_remote::{self, CatalogDiff};
|
||||
use crate::cli::CatalogCmd;
|
||||
use crate::commands::config_cmd::register_include;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::print_json;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub fn run(app: &App, cmd: &CatalogCmd) -> Result<i32> {
|
||||
match cmd {
|
||||
CatalogCmd::Update => update(app),
|
||||
CatalogCmd::Add { url } => add(app, url),
|
||||
CatalogCmd::List => list(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// am catalog update: fetch the official catalog, show the diff against the
|
||||
/// local catalog, ask for confirmation, then register the URL as an include.
|
||||
fn update(app: &App) -> Result<i32> {
|
||||
let url = catalog_remote::official_url(app);
|
||||
app.log.info(&format!("fetching the official catalog: {url}"));
|
||||
let text = catalog_remote::fetch(app, &url, true)?;
|
||||
let remote = catalog_remote::parse_catalog(&text)?;
|
||||
let diff = catalog_remote::diff_catalogs(&app.config, &remote);
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"url": url,
|
||||
"agents": remote.agents.len(),
|
||||
"added": diff.added,
|
||||
"removed": diff.removed,
|
||||
"changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::<Vec<_>>(),
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
print_diff(&diff);
|
||||
if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
|
||||
app.log
|
||||
.success(&format!("catalog is up to date ({} agents)", remote.agents.len()));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry("would register the official catalog as an include");
|
||||
return Ok(0);
|
||||
}
|
||||
if !app
|
||||
.confirm(&format!(
|
||||
"apply this catalog ({} agents, {} changes)?",
|
||||
remote.agents.len(),
|
||||
diff.added.len() + diff.removed.len() + diff.changed.len()
|
||||
))?
|
||||
{
|
||||
app.log.info("catalog update cancelled");
|
||||
return Ok(0);
|
||||
}
|
||||
register_include(app, &url)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Catalog)
|
||||
.with_args(vec![url.clone()])
|
||||
.with_reason("update"),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"official catalog registered as an include ({url}) — it is effective from the next run"
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am catalog add <url>: validate and register an external catalog.
|
||||
fn add(app: &App, url: &str) -> Result<i32> {
|
||||
crate::download::validate_url(url)?;
|
||||
if config_cmd::user_includes(app).iter().any(|i| i == url) {
|
||||
app.log
|
||||
.info(&format!("catalog {url} is already registered as an include"));
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!("fetching catalog: {url}"));
|
||||
// force: 'catalog add' must validate the current content of the URL,
|
||||
// not a possibly stale cached copy.
|
||||
let text = catalog_remote::fetch(app, url, true)?;
|
||||
let remote = catalog_remote::parse_catalog(&text)?;
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"url": url,
|
||||
"agents": remote.agents.len(),
|
||||
"registered": false,
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
if remote.agents.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"the catalog at {url} defines no agents — refusing to register it"
|
||||
));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would register {url} ({:?} agents)",
|
||||
remote.agents.len()
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
register_include(app, url)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Catalog)
|
||||
.with_args(vec![url.to_string()])
|
||||
.with_reason("add"),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"catalog {url} registered ({} agents) — effective from the next run",
|
||||
remote.agents.len()
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am catalog list: show the registered remote catalogs (includes).
|
||||
/// The resolved config consumes its includes at load time, so this reads
|
||||
/// the user config file directly (like 'config add').
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let urls: Vec<String> = config_cmd::user_includes(app)
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.starts_with("http://")
|
||||
|| i.starts_with("https://")
|
||||
|| i.starts_with("file://")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if app.json() {
|
||||
print_json(&urls);
|
||||
return Ok(0);
|
||||
}
|
||||
if urls.is_empty() {
|
||||
app.log.info(
|
||||
"no remote catalog registered — 'am catalog add <url>' or 'am catalog update'",
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
println!("remote catalogs ({}):", urls.len());
|
||||
for u in &urls {
|
||||
println!(" - {u}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn print_diff(diff: &CatalogDiff) {
|
||||
if !diff.added.is_empty() {
|
||||
println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", "));
|
||||
}
|
||||
if !diff.removed.is_empty() {
|
||||
println!(
|
||||
" - {} removed: {}",
|
||||
diff.removed.len(),
|
||||
diff.removed.join(", ")
|
||||
);
|
||||
}
|
||||
if !diff.changed.is_empty() {
|
||||
println!(" ~ {} changed run command(s):", diff.changed.len());
|
||||
for (name, from, to) in &diff.changed {
|
||||
println!(" {name}: '{from}' -> '{to}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-15
@@ -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
|
||||
@@ -217,7 +224,42 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
let dest = dir.join(&base);
|
||||
std::fs::copy(file, &dest)
|
||||
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
|
||||
register_include(app, &base)?;
|
||||
app.log.success(&format!(
|
||||
"added {} and registered it in {}",
|
||||
dest.display(),
|
||||
user_config_path(app)?.display()
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Read the `include:` entries of the user config file (they are consumed
|
||||
/// at load time, so the resolved config no longer lists them).
|
||||
pub fn user_includes(app: &App) -> Vec<String> {
|
||||
let Some(cfg_path) = user_config_path_if_any(app) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
|
||||
return Vec::new();
|
||||
};
|
||||
value
|
||||
.get("include")
|
||||
.and_then(|i| i.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add one entry to the `include:` list of the user config file (creating
|
||||
/// the file with a template when it does not exist). Used by 'config add'
|
||||
/// (local files) and 'catalog add/update' (remote URLs, issues #60 #67).
|
||||
pub fn register_include(app: &App, entry: &str) -> Result<()> {
|
||||
let cfg_path = user_config_path(app)?;
|
||||
let text = std::fs::read_to_string(&cfg_path)?;
|
||||
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
|
||||
@@ -227,7 +269,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
let key = serde_yaml::Value::String("include".to_string());
|
||||
match map.get_mut(&key) {
|
||||
Some(serde_yaml::Value::Sequence(seq)) => {
|
||||
let entry = serde_yaml::Value::String(base.clone());
|
||||
let entry = serde_yaml::Value::String(entry.to_string());
|
||||
if !seq.contains(&entry) {
|
||||
seq.push(entry);
|
||||
}
|
||||
@@ -236,7 +278,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
map.insert(
|
||||
key,
|
||||
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
|
||||
base.clone(),
|
||||
entry.to_string(),
|
||||
)]),
|
||||
);
|
||||
}
|
||||
@@ -247,16 +289,11 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
_ => {
|
||||
std::fs::write(
|
||||
&cfg_path,
|
||||
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
|
||||
format!("version: \"1.0\"\ninclude:\n - {entry}\n"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
app.log.success(&format!(
|
||||
"added {} and registered it in {}",
|
||||
dest.display(),
|
||||
cfg_path.display()
|
||||
));
|
||||
Ok(0)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
|
||||
use crate::runner::SystemRunner;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
pub fn run(app: &App, fix: bool, watch: Option<u64>) -> Result<i32> {
|
||||
if let Some(interval) = watch {
|
||||
return watch_loop(app, interval.max(1));
|
||||
}
|
||||
if fix {
|
||||
let repaired = doctor::fix(app)?;
|
||||
for r in &repaired {
|
||||
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
}
|
||||
Ok(if report.ok() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
/// Issue #59: re-run the doctor checks every N seconds; alert (desktop
|
||||
/// notification + event) on the first failure, recover silently. Clean
|
||||
/// Ctrl-C via ctrlc.
|
||||
fn watch_loop(app: &App, interval: u64) -> Result<i32> {
|
||||
app.log
|
||||
.info(&format!("watching every {interval}s (Ctrl-C to stop)"));
|
||||
let mut was_ok = true;
|
||||
loop {
|
||||
let runner = SystemRunner::new(false, false, &app.log);
|
||||
let report = doctor::run(app, &runner);
|
||||
let ok = report.ok();
|
||||
if !ok && was_ok {
|
||||
// First failure: alert.
|
||||
app.emit(
|
||||
&Event::now(EventKind::Doctor)
|
||||
.with_exit_code(1)
|
||||
.with_reason(format!("{} problem(s) found", report.problems())),
|
||||
);
|
||||
app.log.error(&format!(
|
||||
"{} problem(s) found — 'am doctor --fix' repairs what it can",
|
||||
report.problems()
|
||||
));
|
||||
let _ = crate::process::desktop_notify(
|
||||
"agent-manager",
|
||||
&format!("doctor: {} problem(s)", report.problems()),
|
||||
);
|
||||
was_ok = false;
|
||||
} else if ok && !was_ok {
|
||||
app.log.success("environment recovered");
|
||||
was_ok = true;
|
||||
} else if !ok {
|
||||
app.log
|
||||
.warn(&format!("still {} problem(s)", report.problems()));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(interval));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,37 @@ fn rerun_entry(app: &App, n: usize) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter history entries; returns them most recent first.
|
||||
/// Parse "12..25" (inclusive bounds, 1-based) or a single "12".
|
||||
fn parse_range(r: &str) -> Result<(usize, usize)> {
|
||||
let r = r.trim();
|
||||
if r.is_empty() {
|
||||
anyhow::bail!("empty range — usage: am history <from>..<to> --save <file>");
|
||||
}
|
||||
if let Some((a, b)) = r.split_once("..") {
|
||||
let from = a
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
|
||||
let to = b
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
|
||||
if from == 0 || to == 0 {
|
||||
anyhow::bail!("history entries start at 1");
|
||||
}
|
||||
Ok((from, to))
|
||||
} else {
|
||||
let n = r
|
||||
.parse::<usize>()
|
||||
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("history entries start at 1");
|
||||
}
|
||||
Ok((n, n))
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter history entries; returns them most recent first.
|
||||
pub fn filter(
|
||||
app: &App,
|
||||
@@ -96,11 +127,25 @@ pub fn run(
|
||||
session: Option<&str>,
|
||||
limit: usize,
|
||||
rerun: Option<usize>,
|
||||
range: Option<&str>,
|
||||
save: Option<&Path>,
|
||||
) -> Result<i32> {
|
||||
history::migrate(app)?;
|
||||
if let Some(n) = rerun {
|
||||
return rerun_entry(app, n);
|
||||
}
|
||||
// Issue #51: 'am history 12..25 --save <file>' exports a playbook.
|
||||
if let Some(r) = range {
|
||||
let file = save.ok_or_else(|| anyhow!("--save <file> is required with a range"))?;
|
||||
let (from, to) = parse_range(r)?;
|
||||
let (n, path) = crate::playbook::export_range(app, from, to, file)?;
|
||||
app.log
|
||||
.success(&format!("playbook saved: {} ({n} step(s))", path.display()));
|
||||
return Ok(0);
|
||||
}
|
||||
if save.is_some() {
|
||||
anyhow::bail!("usage: am history <from>..<to> --save <file>");
|
||||
}
|
||||
let rows = filter(app, kind, cwd, search, failed, session, limit);
|
||||
if app.json() {
|
||||
let payload: Vec<serde_json::Value> = rows
|
||||
|
||||
+54
-29
@@ -1,5 +1,6 @@
|
||||
//! init: generate a local agent-manager.yaml for the current directory,
|
||||
//! based on the detected technology stack.
|
||||
//! based on the detected technology stack or an explicit template
|
||||
//! (issue #52: 'am init --template web|python|rust|cli').
|
||||
|
||||
use super::*;
|
||||
use crate::context;
|
||||
@@ -7,8 +8,9 @@ use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Generate a local config file in dir; refuses to overwrite unless force.
|
||||
/// Returns the written path.
|
||||
pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
|
||||
/// `template` renders a pre-filled config for a stack ('am init --template');
|
||||
/// None falls back on the automatic stack detection. Returns the written path.
|
||||
pub fn generate(dir: &Path, force: bool, template: Option<&str>) -> Result<PathBuf> {
|
||||
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
|
||||
if target.exists() && !force {
|
||||
return Err(anyhow!(
|
||||
@@ -16,34 +18,51 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
let ctx = context::detect(dir);
|
||||
let agents = context::recommended_agents(&ctx.stack);
|
||||
let members = agents
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let stack = if ctx.stack.is_empty() {
|
||||
"aucune détectée".to_string()
|
||||
let project = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "project".to_string());
|
||||
let dir_str = dir.to_string_lossy().to_string();
|
||||
let content = if let Some(t) = template {
|
||||
crate::templates::render(t, &project, &dir_str)?
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
};
|
||||
let content = format!(
|
||||
"# Généré par 'am init' le {date}
|
||||
let ctx = context::detect(dir);
|
||||
let agents = context::recommended_agents(&ctx.stack);
|
||||
let members = agents
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let stack = if ctx.stack.is_empty() {
|
||||
crate::i18n::tr("aucune détectée").to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
};
|
||||
format!(
|
||||
"# Généré par 'am init' le {date}
|
||||
# Pile détectée : {stack}
|
||||
version: \"1.0\"
|
||||
groups:
|
||||
dev: [{members}]
|
||||
agents: []
|
||||
",
|
||||
date = crate::installers::now_rfc3339(),
|
||||
);
|
||||
date = crate::installers::now_rfc3339(),
|
||||
)
|
||||
};
|
||||
std::fs::write(&target, content)
|
||||
.with_context(|| format!("cannot write {}", target.display()))?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
pub fn run(app: &App, force: bool) -> Result<i32> {
|
||||
pub fn run(app: &App, force: bool, template: Option<&str>) -> Result<i32> {
|
||||
// 'am init --template list' prints the available templates.
|
||||
if template == Some("list") {
|
||||
println!("available templates:");
|
||||
for (name, desc) in crate::templates::TEMPLATES {
|
||||
println!(" {name:<8} {desc}");
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would generate {} in the current directory",
|
||||
@@ -53,16 +72,22 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
|
||||
}
|
||||
let cwd = std::env::current_dir().context("cannot determine the current directory")?;
|
||||
let ctx = context::detect(&cwd);
|
||||
let path = generate(&cwd, force)?;
|
||||
app.log.success(&format!(
|
||||
"wrote {} (stack: {})",
|
||||
path.display(),
|
||||
if ctx.stack.is_empty() {
|
||||
"aucune détectée".to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
}
|
||||
));
|
||||
let path = generate(&cwd, force, template)?;
|
||||
match template {
|
||||
Some(t) => app.log.success(&format!(
|
||||
"wrote {} (template: {t})",
|
||||
path.display()
|
||||
)),
|
||||
None => app.log.success(&format!(
|
||||
"wrote {} (stack: {})",
|
||||
path.display(),
|
||||
if ctx.stack.is_empty() {
|
||||
crate::i18n::tr("aucune détectée").to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
}
|
||||
)),
|
||||
}
|
||||
app.log.info(&format!(
|
||||
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
|
||||
));
|
||||
|
||||
+120
-2
@@ -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))))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! lab: benchmark the same task on several agents (issue #62).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::LabArgs;
|
||||
use crate::lab::{self, LabReport};
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, args: &LabArgs) -> Result<i32> {
|
||||
if args.list {
|
||||
let tasks = lab::list_tasks(app);
|
||||
if app.json() {
|
||||
let names: Vec<String> = tasks.iter().map(|p| p.display().to_string()).collect();
|
||||
crate::output::print_json(&names);
|
||||
return Ok(0);
|
||||
}
|
||||
if tasks.is_empty() {
|
||||
app.log.info(&format!(
|
||||
"no tasks in {} — write a task.yaml (name, args, timeout) or run 'lab --agents a,b --task hello'",
|
||||
lab::dir(app).display()
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = crate::output::Table::new(vec!["TASK"]);
|
||||
for p in tasks {
|
||||
table.row(vec![p.display().to_string()]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let path = match args.task.as_deref() {
|
||||
Some(p) => lab::resolve_task(app, p),
|
||||
None => anyhow::bail!("--task <file> is required"),
|
||||
};
|
||||
if !path.exists() {
|
||||
anyhow::bail!(
|
||||
"task not found: {} — write it or use 'lab --list' to see the tasks of {}",
|
||||
path.display(),
|
||||
lab::dir(app).display()
|
||||
);
|
||||
}
|
||||
let task = lab::load_task(&path)?;
|
||||
let agents = lab::split_agents(args.agents.as_deref().unwrap_or(""));
|
||||
if agents.is_empty() {
|
||||
anyhow::bail!(
|
||||
"--agents expects at least one agent name, got '{}'",
|
||||
args.agents.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
app.log.info(&format!(
|
||||
"lab '{}' — {} agent(s), {}",
|
||||
task.name,
|
||||
agents.len(),
|
||||
if args.parallel { "parallèle" } else { "séquentiel" }
|
||||
));
|
||||
let report = lab::run_benchmark(app, &agents, &task, args.parallel, args.timeout)?;
|
||||
if app.json() {
|
||||
crate::output::print_json(&report);
|
||||
} else {
|
||||
print_report(app, &report);
|
||||
}
|
||||
// Exit code: 0 when every run succeeded, 1 otherwise (CI-friendly).
|
||||
Ok(if report.runs.iter().all(|r| r.status == "ok") {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
})
|
||||
}
|
||||
|
||||
fn print_report(app: &App, report: &LabReport) {
|
||||
let mut table = crate::output::Table::new(vec![
|
||||
"AGENT", "STATUS", "EXIT", "DURATION", "COST", "OUTPUT",
|
||||
]);
|
||||
for r in &report.runs {
|
||||
table.row(vec![
|
||||
r.agent.clone(),
|
||||
r.status.clone(),
|
||||
r.exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
format!("{:.2}s", r.duration_ms as f64 / 1000.0),
|
||||
r.cost_usd
|
||||
.map(|c| format!("${c:.4}"))
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
crate::output::truncate(r.output.lines().next().unwrap_or(""), 40),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
let s = &report.summary;
|
||||
let mut line = format!(
|
||||
"{} run(s) — {} ok, {} failed, {} timeout",
|
||||
s.runs, s.ok, s.failed, s.timeout
|
||||
);
|
||||
if let Some(f) = &s.fastest {
|
||||
line.push_str(&format!(" — fastest: {f}"));
|
||||
}
|
||||
if let Some(c) = &s.cheapest {
|
||||
line.push_str(&format!(" — cheapest: {c}"));
|
||||
}
|
||||
app.log.info(&line);
|
||||
}
|
||||
+22
-8
@@ -75,14 +75,14 @@ pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogRow {
|
||||
ts: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
pid: Option<u32>,
|
||||
exit_code: Option<i32>,
|
||||
duration_s: Option<u64>,
|
||||
session: Option<String>,
|
||||
pub struct LogRow {
|
||||
pub ts: String,
|
||||
pub kind: String,
|
||||
pub agent: Option<String>,
|
||||
pub pid: Option<u32>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub duration_s: Option<u64>,
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Event> for LogRow {
|
||||
@@ -99,6 +99,20 @@ impl From<&Event> for LogRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows for the web API (issue #54): same shape as `am log --json`.
|
||||
pub fn rows(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
kinds: Option<&str>,
|
||||
since: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<LogRow>> {
|
||||
Ok(filter_events(app, agent, kinds, since, limit)?
|
||||
.iter()
|
||||
.map(LogRow::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Chars of text after byte offset seen (UTF-8 safe).
|
||||
pub fn text_after(text: &str, seen: usize) -> String {
|
||||
text.char_indices()
|
||||
|
||||
+338
-3
@@ -1,11 +1,15 @@
|
||||
//! man: generate roff man pages from the clap definition (issue #41).
|
||||
//! 'am man' prints am.1 for the whole CLI; 'am man <command>' prints one
|
||||
//! page; '--output DIR' writes am.1 plus one am-<command>.1 per command.
|
||||
//!
|
||||
//! On a terminal the page is rendered to readable, themed text (there is no
|
||||
//! man viewer on Windows); when stdout is piped the raw roff source is kept
|
||||
//! so 'am man | man -l -' and scripts keep working unchanged.
|
||||
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
use clap::CommandFactory;
|
||||
use std::io::Write;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i32> {
|
||||
@@ -30,7 +34,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
dir.join(format!("am-{name}.1")).display()
|
||||
));
|
||||
}
|
||||
None => man.render(&mut std::io::stdout())?,
|
||||
None => print_page(app, &man)?,
|
||||
}
|
||||
}
|
||||
None => match output {
|
||||
@@ -51,7 +55,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
cmd.get_subcommands().count()
|
||||
));
|
||||
}
|
||||
None => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
|
||||
None => print_page(app, &clap_mangen::Man::new(cmd))?,
|
||||
},
|
||||
}
|
||||
Ok(0)
|
||||
@@ -68,3 +72,334 @@ fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> {
|
||||
file.flush().ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print one man page: raw roff when stdout is piped (scripts, 'man -l -'),
|
||||
/// a readable themed rendering when it is a terminal.
|
||||
fn print_page(app: &App, man: &clap_mangen::Man) -> Result<()> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
man.render(&mut buf)
|
||||
.context("cannot render the man page")?;
|
||||
let roff = String::from_utf8_lossy(&buf);
|
||||
if std::io::stdout().is_terminal() {
|
||||
print!("{}", roff_to_text(&roff, app.theme(), app.color()));
|
||||
} else {
|
||||
std::io::stdout().write_all(roff.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Miniature roff viewer: renders the groff man macro subset emitted by
|
||||
// clap_mangen (.TH .SH .SS .PP .TP .IP .B .I .BR .RB .BI .IB .IR .RI .RS
|
||||
// .RE .nf .fi ...) to plain, themed text.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IND_TERM: &str = " ";
|
||||
const IND_BODY: &str = " ";
|
||||
|
||||
/// Convert the roff source of a man page into readable text, coloured with
|
||||
/// the active theme when color is on.
|
||||
pub fn roff_to_text(roff: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||
let mut out = String::new();
|
||||
let mut level = 0usize; // .RS/.RE nesting
|
||||
let mut in_tp = false; // inside a .TP definition list
|
||||
let mut tp_term: Option<String> = None; // the pending term of a .TP
|
||||
let mut tp_printed = false; // term emitted, further lines are bodies
|
||||
for raw in roff.lines() {
|
||||
let line = raw.trim_end();
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with(".\\\"") {
|
||||
continue;
|
||||
}
|
||||
// Groff conditionals defining \*(Aq — not needed by the renderer.
|
||||
if trimmed.starts_with(".ie ") || trimmed.starts_with(".el ") {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix('.') {
|
||||
let (name, args) = match rest.split_once(char::is_whitespace) {
|
||||
Some((n, a)) => (n, a.trim()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
// Any other macro closes a pending .TP list.
|
||||
if let Some(term) = tp_term.take() {
|
||||
out.push_str(&format!("{IND_TERM}{term}\n"));
|
||||
}
|
||||
in_tp = false;
|
||||
tp_printed = false;
|
||||
match name {
|
||||
"TH" => {
|
||||
let mut parts = args.split_whitespace();
|
||||
let title = parts.next().unwrap_or("am");
|
||||
let sec = parts.next().unwrap_or("1");
|
||||
let meta = parts.collect::<Vec<_>>().join(" ");
|
||||
let meta = meta.trim_matches('"').to_string();
|
||||
let mut line = theme.acc(&format!("{title}({sec})"));
|
||||
if !meta.is_empty() {
|
||||
line.push_str(&format!(" {}", theme.dimmed(&meta)));
|
||||
}
|
||||
out.push_str(&format!("{line}\n\n"));
|
||||
}
|
||||
"SH" => {
|
||||
out.push('\n');
|
||||
let title = conv(args, theme, color);
|
||||
out.push_str(&format!(
|
||||
"{}\n",
|
||||
if color {
|
||||
theme.hdr(&format!("▸ {title}"))
|
||||
} else {
|
||||
format!("▸ {title}")
|
||||
}
|
||||
));
|
||||
}
|
||||
"SS" => {
|
||||
out.push('\n');
|
||||
let title = conv(args, theme, color);
|
||||
out.push_str(&format!(
|
||||
"{}\n",
|
||||
if color {
|
||||
theme.inf(&format!("· {title}"))
|
||||
} else {
|
||||
format!("· {title}")
|
||||
}
|
||||
));
|
||||
}
|
||||
"PP" | "P" | "sp" => out.push('\n'),
|
||||
"br" => out.push('\n'),
|
||||
"TP" => {
|
||||
in_tp = true;
|
||||
tp_term = None;
|
||||
tp_printed = false;
|
||||
}
|
||||
"IP" => {
|
||||
// Indented paragraph, often a bullet (see also lists).
|
||||
in_tp = true;
|
||||
tp_printed = true;
|
||||
out.push_str(&format!("{IND_TERM}{}\n", conv(args, theme, color)));
|
||||
}
|
||||
"B" => out.push_str(&format!("{IND_TERM}{}\n", bold(conv(args, theme, color), color))),
|
||||
"I" => out.push_str(&format!(
|
||||
"{IND_TERM}{}\n",
|
||||
if color {
|
||||
theme.inf(&conv(args, theme, color))
|
||||
} else {
|
||||
conv(args, theme, color)
|
||||
}
|
||||
)),
|
||||
"BR" | "RB" | "BI" | "IB" | "IR" | "RI" => {
|
||||
let mut parts = args.splitn(2, char::is_whitespace);
|
||||
let a = parts.next().unwrap_or("");
|
||||
let b = parts.next().unwrap_or("");
|
||||
let (bold_first, italic_second) = match name {
|
||||
"BR" | "BI" | "IB" | "IR" => (true, matches!(name, "BI" | "IB")),
|
||||
_ => (false, false),
|
||||
};
|
||||
let left = if bold_first {
|
||||
bold(conv(a, theme, color), color)
|
||||
} else {
|
||||
conv(a, theme, color)
|
||||
};
|
||||
let right = if italic_second {
|
||||
if color {
|
||||
theme.inf(&conv(b, theme, color))
|
||||
} else {
|
||||
conv(b, theme, color)
|
||||
}
|
||||
} else {
|
||||
conv(b, theme, color)
|
||||
};
|
||||
out.push_str(&format!("{IND_TERM}{left} {right}\n"));
|
||||
}
|
||||
"RS" => level += 1,
|
||||
"RE" => level = level.saturating_sub(1),
|
||||
// Filling/formatting directives that do not affect this
|
||||
// line-based rendering.
|
||||
"nf" | "fi" | "ad" | "hy" | "in" | "ds" | "EX" | "EE" => {}
|
||||
other => {
|
||||
// Unknown macro: keep its arguments as plain text when
|
||||
// there is something to show.
|
||||
if !args.is_empty() {
|
||||
out.push_str(&format!("{}{}\n", indent(level), conv(args, theme, color)));
|
||||
}
|
||||
let _ = other;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Plain text line: the first line after a .TP is the term, the
|
||||
// following ones its body. The term is emitted as soon as the
|
||||
// first body line arrives, so terms and bodies stay in order.
|
||||
let text = conv(trimmed, theme, color);
|
||||
if in_tp && !tp_printed {
|
||||
if tp_term.is_none() {
|
||||
tp_term = Some(text);
|
||||
} else {
|
||||
if let Some(term) = tp_term.take() {
|
||||
out.push_str(&format!("{IND_TERM}{term}\n"));
|
||||
}
|
||||
out.push_str(&format!("{IND_BODY}{text}\n"));
|
||||
tp_printed = true;
|
||||
}
|
||||
} else if in_tp {
|
||||
out.push_str(&format!("{IND_BODY}{text}\n"));
|
||||
} else {
|
||||
out.push_str(&format!("{}{}\n", indent(level), text));
|
||||
}
|
||||
}
|
||||
if let Some(term) = tp_term.take() {
|
||||
out.push_str(&format!("{IND_TERM}{term}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Indentation for .RS/.RE nesting (plus a small base margin).
|
||||
fn indent(level: usize) -> String {
|
||||
format!("{}{}", " ".repeat(level), " ")
|
||||
}
|
||||
|
||||
/// Bold text: ANSI bold when colors are on, plain otherwise.
|
||||
fn bold(s: String, color: bool) -> String {
|
||||
if color {
|
||||
format!("\x1b[1m{s}\x1b[0m")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert roff inline escapes to text: \\fB/\\fI/\\fR font switches,
|
||||
/// \\*(Aq, \\- (hyphen), \\(bu (bullet), \\& and friends.
|
||||
fn conv(s: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] != '\\' {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(next) = chars.get(i + 1).copied() else {
|
||||
out.push('\\');
|
||||
break;
|
||||
};
|
||||
match next {
|
||||
'f' => {
|
||||
let mode = chars.get(i + 2).copied().unwrap_or('P');
|
||||
if color {
|
||||
match mode {
|
||||
'B' => out.push_str("\x1b[1m"),
|
||||
'I' => out.push_str(&format!("\x1b[{}m", theme.info)),
|
||||
_ => out.push_str("\x1b[0m"),
|
||||
}
|
||||
}
|
||||
i += 3;
|
||||
}
|
||||
'(' => {
|
||||
let a = chars.get(i + 2).copied().unwrap_or(' ');
|
||||
let d = chars.get(i + 3).copied().unwrap_or(' ');
|
||||
let two = format!("{a}{d}");
|
||||
out.push_str(match two.as_str() {
|
||||
"aq" => "'",
|
||||
"bu" => "•",
|
||||
"em" => "—",
|
||||
"lq" => "\u{201c}",
|
||||
"rq" => "\u{201d}",
|
||||
_ => two.as_str(),
|
||||
});
|
||||
i += 4;
|
||||
}
|
||||
'*' => {
|
||||
// \*(xx : the '(' opens the two-character string name.
|
||||
let a = chars.get(i + 3).copied().unwrap_or(' ');
|
||||
let d = chars.get(i + 4).copied().unwrap_or(' ');
|
||||
let name = format!("{a}{d}");
|
||||
out.push_str(match name.as_str() {
|
||||
"Aq" | "Sq" => "'",
|
||||
_ => "",
|
||||
});
|
||||
i += 5;
|
||||
}
|
||||
'-' => {
|
||||
out.push('-');
|
||||
i += 2;
|
||||
}
|
||||
'&' => i += 2,
|
||||
'e' => {
|
||||
out.push('\\');
|
||||
i += 2;
|
||||
}
|
||||
' ' => {
|
||||
out.push(' ');
|
||||
i += 2;
|
||||
}
|
||||
'\\' => {
|
||||
out.push('\\');
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::default_theme;
|
||||
|
||||
fn plain(roff: &str) -> String {
|
||||
roff_to_text(roff, default_theme(), false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_sections_terms_and_bodies() {
|
||||
let text = plain(
|
||||
".TH am 1 \"am 0.4.3\"\n\
|
||||
.SH NAME\n\
|
||||
am \\- agent manager\n\
|
||||
.SH OPTIONS\n\
|
||||
.TP\n\
|
||||
\\fB\\-c\\fR, \\fB\\-\\-config\\fR \\fI<FILE>\\fR\n\
|
||||
Use an alternate configuration file\n",
|
||||
);
|
||||
assert!(text.contains("am(1)"), "{text}");
|
||||
assert!(text.contains("▸ NAME"), "{text}");
|
||||
assert!(text.contains("▸ OPTIONS"), "{text}");
|
||||
assert!(text.contains("am - agent manager"), "{text}");
|
||||
assert!(text.contains("--config"), "{text}");
|
||||
assert!(text.contains("Use an alternate configuration file"), "{text}");
|
||||
assert!(!text.contains("\\fB"), "font escapes must be converted: {text}");
|
||||
assert!(!text.contains(".SH"), "macros must be removed: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_special_characters() {
|
||||
let t = default_theme();
|
||||
assert_eq!(conv("\\*(Aqhello\\*(Aq", t, false), "'hello'");
|
||||
assert_eq!(conv("\\-\\-dry\\-run", t, false), "--dry-run");
|
||||
assert_eq!(conv("\\(bu", t, false), "•");
|
||||
assert_eq!(conv("\\&x", t, false), "x");
|
||||
assert_eq!(conv("a\\e\\eb", t, false), "a\\\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_bold_and_italic_segments_when_enabled() {
|
||||
let t = default_theme();
|
||||
let s = conv("\\fBam\\fR \\fIvalue\\fR", t, true);
|
||||
assert!(s.contains("\x1b[1m"), "{s}");
|
||||
assert!(s.contains(&format!("\x1b[{}m", t.info)), "{s}");
|
||||
assert!(s.contains("\x1b[0m"), "{s}");
|
||||
// Without colors the same text is plain.
|
||||
assert_eq!(conv("\\fBam\\fR \\fIvalue\\fR", t, false), "am value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tp_term_is_flushed_at_the_next_macro() {
|
||||
let text = plain(".TP\n\\fBam\\fR\nbody line\n.PP\nnext paragraph\n");
|
||||
assert!(text.contains(" am\n"), "{text}");
|
||||
assert!(text.contains(" body line"), "{text}");
|
||||
assert!(text.contains("next paragraph"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! migrate: transfer the installation and state from machine A to machine B
|
||||
//! (issue #68). `am migrate export` bundles config, state, journal, history,
|
||||
//! custom catalogs and backups into a single .amx file with a manifest of
|
||||
//! hashes; `am migrate import` restores it and runs `am doctor` afterwards.
|
||||
//! Absolute paths are neutralized ({{STATE_DIR}} / {{HOME}} placeholders) so
|
||||
//! the bundle is portable. No runtime dependency: the bundle format is a
|
||||
//! JSON manifest line followed by concatenated blobs.
|
||||
|
||||
use super::*;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const BUNDLE_FORMAT: &str = "am-migrate-bundle";
|
||||
pub const BUNDLE_VERSION: u32 = 1;
|
||||
|
||||
/// One file inside the bundle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleFile {
|
||||
/// Relative path inside the state layout ("state.json", "events/...", ...).
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
/// Byte offset of the blob inside the bundle.
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
/// Bundle header (first line of the file, then the blobs).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleManifest {
|
||||
pub format: String,
|
||||
pub version: u32,
|
||||
pub created: String,
|
||||
pub am_version: String,
|
||||
pub files: Vec<BundleFile>,
|
||||
}
|
||||
|
||||
/// sha256 hex of a byte slice.
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(data);
|
||||
let out = h.finalize();
|
||||
out.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Neutralize absolute paths of machine A into portable placeholders.
|
||||
/// The source paths are replaced only when they match the current machine.
|
||||
pub fn neutralize(text: &str, state_dir: &Path, home: &Path) -> String {
|
||||
let mut out = text.to_string();
|
||||
let sd = state_dir.to_string_lossy().to_string();
|
||||
let h = home.to_string_lossy().to_string();
|
||||
out = out.replace(&sd, "{{STATE_DIR}}");
|
||||
out = out.replace(&h, "{{HOME}}");
|
||||
out
|
||||
}
|
||||
|
||||
/// Restore the placeholders with machine B paths.
|
||||
pub fn deneutralize(text: &str, state_dir: &Path, home: &Path) -> String {
|
||||
let mut out = text.to_string();
|
||||
out = out.replace("{{STATE_DIR}}", &state_dir.to_string_lossy());
|
||||
out = out.replace("{{HOME}}", &home.to_string_lossy());
|
||||
out
|
||||
}
|
||||
|
||||
/// Collect the files to bundle: config, state, journal, history, catalog
|
||||
/// cache, backups. Returns (relative path, absolute path, needs path
|
||||
/// neutralization).
|
||||
fn collect_files(app: &App) -> Vec<(String, PathBuf, bool)> {
|
||||
let state = app.events_dir();
|
||||
let mut out: Vec<(String, PathBuf, bool)> = Vec::new();
|
||||
|
||||
// Active config file (when it exists on disk).
|
||||
if let Some(dir) = &app.paths.config_dir {
|
||||
let cfg = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if cfg.exists() {
|
||||
out.push(("config.yaml".to_string(), cfg, true));
|
||||
}
|
||||
}
|
||||
// State file.
|
||||
let sf = app.paths.state_file.clone();
|
||||
if sf.exists() {
|
||||
out.push(("state.json".to_string(), sf, true));
|
||||
}
|
||||
// Journal events.
|
||||
let events = state.join("events");
|
||||
if let Ok(entries) = std::fs::read_dir(&events) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
|
||||
let rel = format!(
|
||||
"events/{}",
|
||||
p.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
out.push((rel, p, true));
|
||||
}
|
||||
}
|
||||
} else if let Ok(entries) = std::fs::read_dir(&state) {
|
||||
// Older layout: journal files sit directly in the state directory.
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if name.starts_with("events-") && name.ends_with(".jsonl") {
|
||||
out.push((name, p, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
// History files (per-session JSONL).
|
||||
let history = state.join("history");
|
||||
if let Ok(entries) = std::fs::read_dir(&history) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let rel = format!(
|
||||
"history/{}",
|
||||
p.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
out.push((rel, p, true));
|
||||
}
|
||||
}
|
||||
// Catalog cache (custom catalogs).
|
||||
let cache = state.join("catalog-cache.json");
|
||||
if cache.exists() {
|
||||
out.push(("catalog-cache.json".to_string(), cache, true));
|
||||
}
|
||||
// Backups (recursive: one entry per file under backups/<id>/).
|
||||
let backups = state.join("backups");
|
||||
if let Ok(entries) = std::fs::read_dir(&backups) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let id = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if let Ok(files) = std::fs::read_dir(&p) {
|
||||
for f in files.flatten() {
|
||||
let fp = f.path();
|
||||
if fp.is_file() {
|
||||
let rel = format!(
|
||||
"backups/{}/{}",
|
||||
id,
|
||||
fp.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
out.push((rel, fp, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write the bundle. Returns the number of bundled files.
|
||||
pub fn export_bundle(app: &App, output: &Path) -> Result<usize> {
|
||||
let files = collect_files(app);
|
||||
let mut manifest = BundleManifest {
|
||||
format: BUNDLE_FORMAT.to_string(),
|
||||
version: BUNDLE_VERSION,
|
||||
created: crate::installers::now_rfc3339(),
|
||||
am_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
files: Vec::new(),
|
||||
};
|
||||
let state_dir = app.events_dir();
|
||||
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let mut blobs: Vec<Vec<u8>> = Vec::new();
|
||||
let mut offset: u64 = 0;
|
||||
|
||||
for (rel, abs, neutralize_paths) in &files {
|
||||
let raw = std::fs::read(abs)
|
||||
.with_context(|| format!("cannot read {}", abs.display()))?;
|
||||
let data = if *neutralize_paths {
|
||||
// Text files: neutralize absolute paths (works on both YAML and
|
||||
// JSON since the placeholders are plain strings).
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let neutral = neutralize(&text, &state_dir, &home);
|
||||
neutral.into_bytes()
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
manifest.files.push(BundleFile {
|
||||
path: rel.clone(),
|
||||
size: data.len() as u64,
|
||||
sha256: sha256_hex(&data),
|
||||
offset,
|
||||
});
|
||||
offset += data.len() as u64;
|
||||
blobs.push(data);
|
||||
}
|
||||
|
||||
if let Some(parent) = output.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut f = std::fs::File::create(output)
|
||||
.with_context(|| format!("cannot create {}", output.display()))?;
|
||||
let header = serde_json::to_string(&manifest)?;
|
||||
writeln!(f, "{header}")?;
|
||||
for b in &blobs {
|
||||
f.write_all(b)?;
|
||||
}
|
||||
f.flush()?;
|
||||
Ok(manifest.files.len())
|
||||
}
|
||||
|
||||
/// Read and verify the bundle: parses the header, checks that every blob is
|
||||
/// present and its hash matches. Returns (manifest, blob bytes).
|
||||
pub fn read_bundle(path: &Path) -> Result<(BundleManifest, Vec<Vec<u8>>)> {
|
||||
let mut raw = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.with_context(|| format!("cannot open {}", path.display()))?
|
||||
.read_to_end(&mut raw)?;
|
||||
let nl = raw
|
||||
.iter()
|
||||
.position(|&b| b == b'\n')
|
||||
.ok_or_else(|| anyhow!("{}: missing header line", path.display()))?;
|
||||
let header: BundleManifest = serde_json::from_slice(&raw[..nl])
|
||||
.with_context(|| format!("{}: invalid bundle header", path.display()))?;
|
||||
if header.format != BUNDLE_FORMAT {
|
||||
anyhow::bail!("{}: not an am-migrate bundle", path.display());
|
||||
}
|
||||
let body = &raw[nl + 1..];
|
||||
let mut blobs = Vec::new();
|
||||
for f in &header.files {
|
||||
let start = f.offset as usize;
|
||||
let end = start + f.size as usize;
|
||||
if end > body.len() {
|
||||
anyhow::bail!(
|
||||
"{}: truncated bundle — '{}' is incomplete (interrupted transfer?)",
|
||||
path.display(),
|
||||
f.path
|
||||
);
|
||||
}
|
||||
let data = body[start..end].to_vec();
|
||||
if sha256_hex(&data) != f.sha256 {
|
||||
anyhow::bail!(
|
||||
"{}: checksum mismatch for '{}' — bundle corrupted",
|
||||
path.display(),
|
||||
f.path
|
||||
);
|
||||
}
|
||||
blobs.push(data);
|
||||
}
|
||||
Ok((header, blobs))
|
||||
}
|
||||
|
||||
/// Restore a bundle into the state layout of machine B. The previous state
|
||||
/// directory is moved aside (resumable, nothing lost). Returns the list of
|
||||
/// restored files.
|
||||
pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<String>> {
|
||||
let (manifest, blobs) = read_bundle(bundle)?;
|
||||
let state_dir = app.events_dir();
|
||||
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
// Resumable import: park the current state aside, then write the new one.
|
||||
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
|
||||
if state_dir.exists() {
|
||||
let park = state_dir.with_extension(format!("prev-{ts}"));
|
||||
std::fs::rename(&state_dir, &park)
|
||||
.with_context(|| format!("cannot park old state at {}", park.display()))?;
|
||||
app.log
|
||||
.info(&format!("previous state moved aside: {}", park.display()));
|
||||
}
|
||||
std::fs::create_dir_all(&state_dir)?;
|
||||
|
||||
let mut restored = Vec::new();
|
||||
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
|
||||
let rel = Path::new(&bf.path);
|
||||
let target = if bf.path == "config.yaml" {
|
||||
// Config goes to the ACTIVE user config directory of this app
|
||||
// (respects --config / isolated tests) — NOT the machine-global
|
||||
// user config dir: importing must not clobber the real config.
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
dir.join(crate::config::CONFIG_FILE_NAME)
|
||||
} else {
|
||||
state_dir.join(rel)
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut content = data.clone();
|
||||
if !rel.starts_with("backups") {
|
||||
// Text content: restore machine B paths.
|
||||
let text = String::from_utf8_lossy(&content);
|
||||
content = deneutralize(&text, &state_dir, &home).into_bytes();
|
||||
}
|
||||
std::fs::write(&target, &content)
|
||||
.with_context(|| format!("cannot write {}", target.display()))?;
|
||||
restored.push(bf.path.clone());
|
||||
}
|
||||
|
||||
// Config merge: the imported config replaces the local one only when
|
||||
// confirmed; otherwise it is written next to it as config.migrated.yaml.
|
||||
let cfg_rel = "config.yaml";
|
||||
if restored.iter().any(|r| r == cfg_rel) && !confirm {
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if imported.exists() {
|
||||
let target = dir.join("config.migrated.yaml");
|
||||
std::fs::rename(&imported, &target)?;
|
||||
app.log.info(&format!(
|
||||
"imported config written as {} (use --yes to replace the local config)",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
restored.sort();
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// am migrate export/import.
|
||||
pub fn run(app: &App, export: bool, output: Option<&Path>, bundle: Option<&Path>) -> Result<i32> {
|
||||
if export {
|
||||
let default_out = PathBuf::from("agent-manager-migrate.amx");
|
||||
let out = output.unwrap_or(&default_out);
|
||||
let n = export_bundle(app, out)?;
|
||||
app.log.success(&format!(
|
||||
"bundle written to {} ({n} files) — copy it to machine B and run 'am migrate import {}'",
|
||||
out.display(),
|
||||
out.display()
|
||||
));
|
||||
if let Some(dir) = app.paths.config_dir.as_ref() {
|
||||
if dir.join(crate::config::CONFIG_FILE_NAME).exists() {
|
||||
app.log.info("the bundle contains config.yaml, state.json, events, history, catalog cache and backups — absolute paths are neutralized");
|
||||
}
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let bundle = bundle.ok_or_else(|| anyhow!("usage: am migrate import <bundle.amx>"))?;
|
||||
if !bundle.exists() {
|
||||
anyhow::bail!("bundle not found: {}", bundle.display());
|
||||
}
|
||||
let ask = format!(
|
||||
"import {} into this machine? The current state will be moved aside",
|
||||
bundle.display()
|
||||
);
|
||||
let confirmed = app.confirm(&ask)?;
|
||||
if !confirmed {
|
||||
app.log.info("import cancelled");
|
||||
return Ok(0);
|
||||
}
|
||||
let restored = import_bundle(app, bundle, app.cli.yes)?;
|
||||
app.log.success(&format!("imported {} file(s)", restored.len()));
|
||||
|
||||
// Post-import verification (issue #68: doctor passes after import).
|
||||
let problems = crate::commands::doctor_cmd::run(app, false, None)?;
|
||||
if problems > 0 {
|
||||
app.log.warn("doctor found issues after the import — review the report above");
|
||||
} else {
|
||||
app.log.success("doctor: environment OK after import");
|
||||
}
|
||||
app.log.info(
|
||||
"next steps: (1) verify 'am list' and 'am status', (2) reinstall agents if needed ('am install <agent>'), (3) start a session",
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sha256_is_stable() {
|
||||
let h1 = sha256_hex(b"hello");
|
||||
let h2 = sha256_hex(b"hello");
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64);
|
||||
assert_ne!(h1, sha256_hex(b"world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutralization_round_trip() {
|
||||
let state = PathBuf::from("/mnt/machine-a/state");
|
||||
let home = PathBuf::from("/home/user-a");
|
||||
let text = format!(
|
||||
"install_dir: {}\nrun: {}/bin/tool\n",
|
||||
state.display(),
|
||||
home.display()
|
||||
);
|
||||
let neutral = neutralize(&text, &state, &home);
|
||||
assert!(neutral.contains("{{STATE_DIR}}"));
|
||||
assert!(neutral.contains("{{HOME}}"));
|
||||
assert!(!neutral.contains("machine-a"));
|
||||
let state_b = PathBuf::from("/data/machine-b/state");
|
||||
let home_b = PathBuf::from("/home/user-b");
|
||||
let back = deneutralize(&neutral, &state_b, &home_b);
|
||||
assert!(back.contains("/data/machine-b/state"));
|
||||
assert!(back.contains("/home/user-b"));
|
||||
}
|
||||
|
||||
fn test_app(tag: &str) -> crate::app::App {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
" confirm_before_run: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
use clap::Parser;
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
|
||||
let mut app = crate::app::App::from_cli(cli).expect("app should build");
|
||||
app.paths.state_file = dir.path().join(format!("state-{tag}/state.json"));
|
||||
app.paths.config_dir = Some(dir.path().to_path_buf());
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_import_round_trip() {
|
||||
let app = test_app("exp");
|
||||
let state = app.events_dir();
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
std::fs::write(app.paths.state_file.clone(), "{\"version\":2,\"installed\":{}}").unwrap();
|
||||
std::fs::write(state.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap();
|
||||
std::fs::write(state.join("catalog-cache.json"), "{}").unwrap();
|
||||
// The config file referenced by config_dir must exist to be bundled.
|
||||
let cfg_path = app
|
||||
.paths
|
||||
.config_dir
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.join(crate::config::CONFIG_FILE_NAME);
|
||||
std::fs::write(&cfg_path, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
|
||||
let bundle = std::env::temp_dir().join(format!("am-mig-{}.amx", std::process::id()));
|
||||
let n = export_bundle(&app, &bundle).unwrap();
|
||||
assert!(n >= 3);
|
||||
|
||||
// Import into a second app with a different state dir.
|
||||
let app_b = test_app("imp");
|
||||
let restored = import_bundle(&app_b, &bundle, true).unwrap();
|
||||
assert!(restored.iter().any(|r| r == "state.json"));
|
||||
assert!(restored.iter().any(|r| r == "events-202608.jsonl"));
|
||||
// State restored with the same content.
|
||||
let state_b = app_b.events_dir();
|
||||
let sf = state_b.join("state.json");
|
||||
assert!(sf.exists());
|
||||
let text = std::fs::read_to_string(sf).unwrap();
|
||||
assert!(text.contains("\"version\":2"));
|
||||
assert!(state_b.join("events-202608.jsonl").exists());
|
||||
let _ = std::fs::remove_file(&bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_bundle_is_detected() {
|
||||
let app = test_app("trunc");
|
||||
let state = app.events_dir();
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
|
||||
let bundle = std::env::temp_dir().join(format!("am-trunc-{}.amx", std::process::id()));
|
||||
export_bundle(&app, &bundle).unwrap();
|
||||
// Truncate the last byte.
|
||||
let mut data = std::fs::read(&bundle).unwrap();
|
||||
data.pop();
|
||||
std::fs::write(&bundle, data).unwrap();
|
||||
let err = read_bundle(&bundle).unwrap_err().to_string();
|
||||
assert!(err.contains("truncated") || err.contains("checksum"), "{err}");
|
||||
let _ = std::fs::remove_file(&bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_blob_is_detected() {
|
||||
let app = test_app("tamper");
|
||||
let state = app.events_dir();
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
|
||||
let bundle = std::env::temp_dir().join(format!("am-tamp-{}.amx", std::process::id()));
|
||||
export_bundle(&app, &bundle).unwrap();
|
||||
// Flip a byte inside the first blob (after the header line), not in
|
||||
// the header itself.
|
||||
let mut data = std::fs::read(&bundle).unwrap();
|
||||
let nl = data.iter().position(|&b| b == b'\n').unwrap();
|
||||
let mid = nl + 1 + (data.len() - nl - 1) / 2;
|
||||
data[mid] ^= 0xFF;
|
||||
std::fs::write(&bundle, data).unwrap();
|
||||
let err = read_bundle(&bundle).unwrap_err().to_string();
|
||||
assert!(err.contains("checksum"), "{err}");
|
||||
let _ = std::fs::remove_file(&bundle);
|
||||
}
|
||||
}
|
||||
+138
-10
@@ -1,8 +1,11 @@
|
||||
//! Command implementations and dispatch.
|
||||
|
||||
pub mod agents_cmd;
|
||||
pub mod ai_cmd;
|
||||
pub mod alias_cmd;
|
||||
pub mod annotations_cmd;
|
||||
pub mod audit_cmd;
|
||||
pub mod catalog_cmd;
|
||||
pub mod completion_cmd;
|
||||
pub mod config_cmd;
|
||||
pub mod dashboard_cmd;
|
||||
@@ -13,30 +16,46 @@ pub mod history_cmd;
|
||||
pub mod info_cmd;
|
||||
pub mod init_cmd;
|
||||
pub mod install_cmd;
|
||||
pub mod lab_cmd;
|
||||
pub mod list_cmd;
|
||||
pub mod log_cmd;
|
||||
pub mod logs_cmd;
|
||||
pub mod man_cmd;
|
||||
pub mod models_cmd;
|
||||
pub mod monitor_cmd;
|
||||
pub mod migrate_cmd;
|
||||
pub mod open_cmd;
|
||||
pub mod profile_cmd;
|
||||
pub mod projects_cmd;
|
||||
pub mod registry_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod schedule_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod secret_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod self_update;
|
||||
pub mod serve_cmd;
|
||||
pub mod service_cmd;
|
||||
pub mod sessions_cmd;
|
||||
pub mod stats_cmd;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod suggest_cmd;
|
||||
pub mod sync_cmd;
|
||||
pub mod playbook_cmd;
|
||||
pub mod plugins_cmd;
|
||||
pub mod providers_cmd;
|
||||
pub mod theme_cmd;
|
||||
pub mod timeline_cmd;
|
||||
pub mod tip_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
pub mod update_cmd;
|
||||
pub mod watch_cmd;
|
||||
pub mod version_cmd;
|
||||
pub mod watch_cmd;
|
||||
pub mod web_cmd;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::Command;
|
||||
use crate::config::{AgentDef, MethodSpec};
|
||||
use crate::config::{AgentDef, MethodSpec, UpdatePolicy};
|
||||
use crate::state::InstalledEntry;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -82,6 +101,20 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Agents { installed } => agents_cmd::run(app, *installed),
|
||||
Command::Alias(sub) => alias_cmd::run(app, sub),
|
||||
Command::Secret(sub) => secret_cmd::run(app, sub),
|
||||
Command::Models(args) => models_cmd::run(app, args),
|
||||
Command::Registry(args) => registry_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Catalog(sub) => catalog_cmd::run(app, sub),
|
||||
Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
|
||||
Command::Ai(args) => ai_cmd::run(app, args),
|
||||
Command::Setup { roles } => {
|
||||
if *roles {
|
||||
crate::setup::run_roles(app)
|
||||
} else {
|
||||
crate::setup::run(app)
|
||||
}
|
||||
}
|
||||
Command::Start(a) => run_cmd::start(app, a),
|
||||
Command::Stop {
|
||||
agent,
|
||||
@@ -94,7 +127,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
timeout,
|
||||
} => run_cmd::restart(app, start, *force, *timeout),
|
||||
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
|
||||
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
|
||||
Command::Stats { agent, period, costs } => {
|
||||
stats_cmd::run(app, agent.as_deref(), period.as_deref(), *costs)
|
||||
}
|
||||
Command::Monitor { interval, json } => {
|
||||
monitor_cmd::run(app, *interval, *json)
|
||||
}
|
||||
Command::Web(args) => web_cmd::run(app, args.port, args.no_open),
|
||||
Command::Serve(args) => serve_cmd::run(app, args),
|
||||
Command::Sync { message } => sync_cmd::run(app, message.as_deref()),
|
||||
Command::Migrate { export, output, bundle } => {
|
||||
migrate_cmd::run(app, *export, output.as_deref(), bundle.as_deref())
|
||||
}
|
||||
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
|
||||
Command::Report {
|
||||
last_week,
|
||||
@@ -121,6 +165,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
status,
|
||||
show,
|
||||
resume,
|
||||
export,
|
||||
output,
|
||||
retention,
|
||||
} => sessions_cmd::run(
|
||||
app,
|
||||
agent.as_deref(),
|
||||
@@ -128,14 +175,31 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
status.as_deref(),
|
||||
show.as_deref(),
|
||||
resume.as_deref(),
|
||||
export.as_deref(),
|
||||
output.as_deref(),
|
||||
*retention,
|
||||
),
|
||||
Command::Install {
|
||||
agent,
|
||||
method,
|
||||
force,
|
||||
} => install_cmd::run(app, agent, method.as_deref(), *force),
|
||||
provider,
|
||||
model,
|
||||
no_config,
|
||||
} => install_cmd::run(
|
||||
app,
|
||||
&agent,
|
||||
method.as_deref(),
|
||||
*force,
|
||||
provider.as_deref(),
|
||||
model.as_deref(),
|
||||
*no_config,
|
||||
),
|
||||
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
|
||||
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||
Command::Update { agent, all, rollback } => {
|
||||
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
|
||||
}
|
||||
Command::Audit => audit_cmd::run(app),
|
||||
Command::Search { keyword, category, tag } => {
|
||||
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
|
||||
}
|
||||
@@ -147,6 +211,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
session,
|
||||
limit,
|
||||
rerun,
|
||||
range,
|
||||
save,
|
||||
} => history_cmd::run(
|
||||
app,
|
||||
kind.as_deref(),
|
||||
@@ -156,14 +222,67 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
session.as_deref(),
|
||||
*limit,
|
||||
*rerun,
|
||||
range.as_deref(),
|
||||
save.as_deref(),
|
||||
),
|
||||
Command::Playbook { path, var } => playbook_cmd::run(app, path, var),
|
||||
Command::Lab(args) => lab_cmd::run(app, args),
|
||||
Command::Plugins(args) => plugins_cmd::run(app, args),
|
||||
Command::Info { agent } => info_cmd::run(app, agent),
|
||||
Command::Init { force } => init_cmd::run(app, *force),
|
||||
Command::Init { force, template } => {
|
||||
init_cmd::run(app, *force, template.as_deref())
|
||||
}
|
||||
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||
Command::Version => version_cmd::run(app),
|
||||
Command::Tip { random } => tip_cmd::run(app, *random),
|
||||
Command::Config(sub) => config_cmd::run(app, sub),
|
||||
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
||||
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
|
||||
Command::Doctor { fix, watch } => {
|
||||
doctor_cmd::run(app, *fix, *watch)
|
||||
}
|
||||
Command::Run { agent, model, provider, container, no_sandbox, args } => {
|
||||
run_cmd::run(
|
||||
app,
|
||||
agent,
|
||||
model.as_deref(),
|
||||
provider.as_deref(),
|
||||
*container,
|
||||
*no_sandbox,
|
||||
args,
|
||||
)
|
||||
}
|
||||
Command::Service(svc) => match svc {
|
||||
crate::cli::ServiceCmd::Install { agent, autostart } => {
|
||||
service_cmd::run(app, "install", Some(agent), *autostart)
|
||||
}
|
||||
crate::cli::ServiceCmd::Uninstall { agent } => {
|
||||
service_cmd::run(app, "uninstall", Some(agent), false)
|
||||
}
|
||||
crate::cli::ServiceCmd::Status { agent } => {
|
||||
service_cmd::run(app, "status", Some(agent), false)
|
||||
}
|
||||
},
|
||||
Command::Schedule(sched) => match sched {
|
||||
crate::cli::ScheduleCmd::Add { command, at } => {
|
||||
// '--at HH:MM' may land inside the trailing command args
|
||||
// (allow_hyphen_values): strip it and recover the value.
|
||||
let mut cmd = command.clone();
|
||||
let mut at_value = at.clone();
|
||||
if let Some(idx) = cmd.iter().position(|a| a == "--at") {
|
||||
if idx + 1 < cmd.len() {
|
||||
at_value = at_value.or_else(|| Some(cmd[idx + 1].clone()));
|
||||
cmd.remove(idx + 1);
|
||||
}
|
||||
cmd.remove(idx);
|
||||
}
|
||||
schedule_cmd::run(app, "add", cmd, at_value.as_deref())
|
||||
}
|
||||
crate::cli::ScheduleCmd::List => schedule_cmd::run(app, "list", vec![], None),
|
||||
crate::cli::ScheduleCmd::Remove { id } => {
|
||||
schedule_cmd::run(app, "remove", vec![id.clone()], None)
|
||||
}
|
||||
crate::cli::ScheduleCmd::Run { id } => schedule_cmd::run(app, "run", vec![id.clone()], None),
|
||||
},
|
||||
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
|
||||
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
|
||||
Command::SelfUninstall => self_uninstall::run(app),
|
||||
@@ -220,9 +339,18 @@ pub fn resolve_exec(
|
||||
args.extend(agent.args.iter().cloned());
|
||||
args.extend(extra_args.iter().cloned());
|
||||
let mut env = agent.env.clone();
|
||||
// Issue #91: the `config.env_map` block, resolved against the agent's
|
||||
// provider — api_key stays the @secret reference (resolved below).
|
||||
crate::agent_config::apply_env_map(&mut env, agent, &app.config);
|
||||
env.extend(extra_env.clone());
|
||||
let warnings =
|
||||
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
|
||||
crate::secrets::resolve_env_secrets(
|
||||
&crate::secrets::store(),
|
||||
&agent.name,
|
||||
crate::agent_config::provider_pref(agent)
|
||||
.or_else(|| crate::providers::default_name(&app.config)),
|
||||
&mut env,
|
||||
);
|
||||
for w in warnings {
|
||||
app.log.warn(&w);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio)
|
||||
//! and pruning of unused models (issues #70 #73, axe 11).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::ModelsArgs;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::models::{self, RuntimeModel};
|
||||
use crate::output::print_json;
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ModelRow {
|
||||
runtime: String,
|
||||
name: String,
|
||||
size_bytes: Option<u64>,
|
||||
quantization: Option<String>,
|
||||
last_used: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuntimeRow {
|
||||
runtime: String,
|
||||
detected: bool,
|
||||
note: Option<String>,
|
||||
models: usize,
|
||||
}
|
||||
|
||||
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
|
||||
if args.prune {
|
||||
return prune(app, args);
|
||||
}
|
||||
let inventory = models::detect(app);
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"runtimes": inventory.runtimes,
|
||||
"models": inventory.models,
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
let theme = app.theme();
|
||||
let color = app.color();
|
||||
// Runtime status lines.
|
||||
for r in &inventory.runtimes {
|
||||
let icon = if r.detected { "✓" } else { "✗" };
|
||||
let note = r.note.as_deref().unwrap_or("");
|
||||
println!(" {icon} {} — {note}", r.runtime);
|
||||
}
|
||||
if !inventory.any_runtime() {
|
||||
app.log.info(
|
||||
"no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API",
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
if inventory.models.is_empty() {
|
||||
app.log.info("runtimes detected but no models found");
|
||||
return Ok(0);
|
||||
}
|
||||
// Table: runtime | model | size | quant | last used.
|
||||
let rows: Vec<Vec<String>> = inventory
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| {
|
||||
vec![
|
||||
m.runtime.clone(),
|
||||
m.name.clone(),
|
||||
human_size(m.size_bytes),
|
||||
m.quantization.clone().unwrap_or_else(|| "—".to_string()),
|
||||
m.last_used.clone().unwrap_or_else(|| "—".to_string()),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let _ = (theme, color);
|
||||
print!(
|
||||
"{}",
|
||||
crate::output::boxed_table(
|
||||
vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"],
|
||||
rows,
|
||||
app.theme(),
|
||||
app.color(),
|
||||
)
|
||||
);
|
||||
println!(
|
||||
"\n {} model(s) — 'am models --prune --dry-run' lists unused ones",
|
||||
inventory.models.len()
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// prune: list unused models, then delete them after confirmation.
|
||||
fn prune(app: &App, args: &ModelsArgs) -> Result<i32> {
|
||||
let days = args.days.unwrap_or_else(|| {
|
||||
app.config
|
||||
.settings
|
||||
.models_prune_days
|
||||
.unwrap_or(models::DEFAULT_PRUNE_DAYS)
|
||||
});
|
||||
let inventory = models::detect(app);
|
||||
let candidates = models::prune_candidates(&inventory.models, days);
|
||||
let _ = &inventory;
|
||||
if app.json() {
|
||||
let rows: Vec<ModelRow> = candidates
|
||||
.iter()
|
||||
.map(|m| ModelRow {
|
||||
runtime: m.runtime.clone(),
|
||||
name: m.name.clone(),
|
||||
size_bytes: m.size_bytes,
|
||||
quantization: m.quantization.clone(),
|
||||
last_used: m.last_used.clone(),
|
||||
})
|
||||
.collect();
|
||||
print_json(&rows);
|
||||
return Ok(if rows.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
app.log.success(&format!(
|
||||
"no unused model found (threshold: {days} days; ':latest' models are always kept)"
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
println!(
|
||||
"{} model(s) unused for more than {days} days (':latest' models are protected):",
|
||||
candidates.len()
|
||||
);
|
||||
for m in &candidates {
|
||||
let size = human_size(m.size_bytes);
|
||||
let last = m.last_used.as_deref().unwrap_or("unknown");
|
||||
println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name);
|
||||
}
|
||||
if args.dry_run {
|
||||
app.log.info("dry-run: nothing was deleted — remove --dry-run to prune");
|
||||
return Ok(0);
|
||||
}
|
||||
let asked = app
|
||||
.confirm(&format!("delete these {} model(s)?", candidates.len()))?;
|
||||
if !asked {
|
||||
app.log.info("prune cancelled");
|
||||
return Ok(0);
|
||||
}
|
||||
let mut removed = 0;
|
||||
let mut errors = 0;
|
||||
for m in &candidates {
|
||||
match models::remove_model(m) {
|
||||
Ok(()) => {
|
||||
app.emit(
|
||||
&Event::now(EventKind::Model)
|
||||
.with_agent(m.runtime.clone())
|
||||
.with_args(vec![m.name.clone()])
|
||||
.with_reason("prune"),
|
||||
);
|
||||
app.log.success(&format!("removed {} ({})", m.name, m.runtime));
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
app.log.error(&format!("{}: {e:#}", m.name));
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The inventory cache is stale after a prune: drop it so the next run
|
||||
// rescans the runtimes.
|
||||
let cache_path = app.events_dir().join("models-cache.json");
|
||||
let _ = std::fs::remove_file(&cache_path);
|
||||
if errors > 0 {
|
||||
app.log
|
||||
.error(&format!("{errors} model(s) could not be removed"));
|
||||
return Ok(1);
|
||||
}
|
||||
app.log
|
||||
.success(&format!("{removed} model(s) removed from the local runtimes"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Human-readable size ("1.2 GB", "450 MB", "—" when unknown).
|
||||
fn human_size(bytes: Option<u64>) -> String {
|
||||
match bytes {
|
||||
None => "—".to_string(),
|
||||
Some(b) => {
|
||||
let kb = b as f64 / 1024.0;
|
||||
let mb = kb / 1024.0;
|
||||
let gb = mb / 1024.0;
|
||||
if gb >= 1.0 {
|
||||
format!("{gb:.1} GB")
|
||||
} else if mb >= 1.0 {
|
||||
format!("{mb:.0} MB")
|
||||
} else {
|
||||
format!("{kb:.0} KB")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage of each model name across sessions (for --json consumers).
|
||||
#[allow(dead_code)]
|
||||
fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap<String, u64> {
|
||||
BTreeMap::new()
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! monitor: real-time TUI of the managed processes (issue #50). Lists every
|
||||
//! running agent with CPU %, RSS, uptime; alerts on configurable thresholds
|
||||
//! (settings.monitor_thresholds) with a desktop notification + journal event.
|
||||
//! `--json` prints one JSON document per tick for scripts.
|
||||
|
||||
use super::*;
|
||||
use crate::config::MonitorThresholds;
|
||||
use crate::events::{Event, EventKind};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One measured process row.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct AgentMetric {
|
||||
pub agent: String,
|
||||
pub pid: u32,
|
||||
pub cpu_pct: f64,
|
||||
pub mem_mb: f64,
|
||||
pub uptime_s: u64,
|
||||
/// "running" when the process answers, "dead" otherwise.
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Raw reading of a process (platform-dependent).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct RawReading {
|
||||
/// Total CPU time consumed so far (seconds).
|
||||
pub cpu_total_s: f64,
|
||||
/// Resident set size in bytes.
|
||||
pub rss_bytes: u64,
|
||||
}
|
||||
|
||||
/// Read the raw counters of a process. Windows: PowerShell Get-Process
|
||||
/// (CPU = total seconds, WorkingSet64 = RSS). Unix: ps.
|
||||
fn raw_reading(pid: u32) -> Option<RawReading> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let out = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"Get-Process -Id {pid} | Select-Object -ExpandProperty CPU; Get-Process -Id {pid} | Select-Object -ExpandProperty WorkingSet64"
|
||||
),
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
|
||||
let cpu: f64 = lines.next()?.trim().parse().ok()?;
|
||||
let rss: u64 = lines.next()?.trim().parse().ok()?;
|
||||
Some(RawReading {
|
||||
cpu_total_s: cpu,
|
||||
rss_bytes: rss,
|
||||
})
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let out = Command::new("ps")
|
||||
.args(["-o", "time=,rss=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut parts = text.split_whitespace();
|
||||
let time = parts.next()?;
|
||||
let rss_kb: u64 = parts.next()?.parse().ok()?;
|
||||
Some(RawReading {
|
||||
cpu_total_s: parse_ps_time(time),
|
||||
rss_bytes: rss_kb * 1024,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `ps` cumulative time "MM:SS" or "HH:MM:SS" into seconds.
|
||||
fn parse_ps_time(t: &str) -> f64 {
|
||||
let parts: Vec<f64> = t
|
||||
.split(':')
|
||||
.filter_map(|p| p.parse().ok())
|
||||
.collect();
|
||||
match parts.as_slice() {
|
||||
[m, s] => m * 60.0 + s,
|
||||
[h, m, s] => h * 3600.0 + m * 60.0 + s,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn raw readings (taken `interval_s` apart) into a metric row.
|
||||
/// Pure: unit tested.
|
||||
pub fn to_metric(
|
||||
agent: &str,
|
||||
pid: u32,
|
||||
before: Option<RawReading>,
|
||||
after: RawReading,
|
||||
started_at: Option<&str>,
|
||||
interval_s: f64,
|
||||
) -> AgentMetric {
|
||||
let cpu_pct = match before {
|
||||
Some(b) if interval_s > 0.0 => {
|
||||
let delta = (after.cpu_total_s - b.cpu_total_s).max(0.0);
|
||||
(delta / interval_s * 100.0).min(100.0 * 64.0) // multi-core cap, generous
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let uptime_s = started_at
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64)
|
||||
.unwrap_or(0);
|
||||
AgentMetric {
|
||||
agent: agent.to_string(),
|
||||
pid,
|
||||
cpu_pct,
|
||||
mem_mb: after.rss_bytes as f64 / (1024.0 * 1024.0),
|
||||
uptime_s,
|
||||
state: "running".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which alerts fire for a metric against the thresholds. Pure.
|
||||
pub fn alerts_for(m: &AgentMetric, t: &MonitorThresholds) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(cpu) = t.cpu_pct {
|
||||
if cpu > 0.0 && m.cpu_pct > cpu {
|
||||
out.push(format!(
|
||||
"{} cpu {:.1}% > {cpu}%",
|
||||
m.agent, m.cpu_pct
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(mem) = t.mem_mb {
|
||||
if mem > 0 && m.mem_mb > mem as f64 {
|
||||
out.push(format!(
|
||||
"{} rss {:.0} MB > {mem} MB",
|
||||
m.agent, m.mem_mb
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Collect the metrics of every managed agent with a live PID.
|
||||
pub fn collect(app: &App, interval_s: f64) -> Vec<AgentMetric> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(sf) = app.state.load() else {
|
||||
return out;
|
||||
};
|
||||
for (name, entry) in &sf.installed {
|
||||
let Some(pid) = entry.pid else { continue };
|
||||
let Some(after) = raw_reading(pid) else {
|
||||
out.push(AgentMetric {
|
||||
agent: name.clone(),
|
||||
pid,
|
||||
state: "dead".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let before = if interval_s > 0.0 {
|
||||
std::thread::sleep(Duration::from_secs_f64(interval_s));
|
||||
raw_reading(pid)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out.push(to_metric(
|
||||
name,
|
||||
pid,
|
||||
before,
|
||||
after,
|
||||
entry.started_at.as_deref(),
|
||||
interval_s,
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit one JSON document (one row per process) — script-friendly.
|
||||
fn print_json_rows(rows: &[AgentMetric]) {
|
||||
crate::output::print_json(&rows);
|
||||
}
|
||||
|
||||
/// Run the monitor: TUI by default, --json for scripts.
|
||||
pub fn run(app: &App, interval: Option<u64>, json: bool) -> Result<i32> {
|
||||
let secs = interval.unwrap_or(2).max(1) as f64;
|
||||
let thresholds = app
|
||||
.config
|
||||
.settings
|
||||
.monitor_thresholds
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
if json {
|
||||
// One JSON document per tick, then sleep; Ctrl-C stops.
|
||||
loop {
|
||||
let rows = collect(app, 0.0);
|
||||
print_json_rows(&rows);
|
||||
for m in &rows {
|
||||
fire_alerts(app, m, &thresholds);
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs_f64(secs));
|
||||
}
|
||||
}
|
||||
tui_loop(app, secs, &thresholds)
|
||||
}
|
||||
|
||||
/// Fire a desktop notification + journal event when a threshold is crossed.
|
||||
/// Alerts are rate-limited by the caller (one per tick).
|
||||
fn fire_alerts(app: &App, m: &AgentMetric, t: &MonitorThresholds) {
|
||||
for alert in alerts_for(m, t) {
|
||||
let _ = crate::process::desktop_notify("agent-manager monitor", &alert);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Alert)
|
||||
.with_agent(m.agent.clone())
|
||||
.with_pid(m.pid)
|
||||
.with_reason(format!("monitor: {alert}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ratatui loop: list of managed processes, refresh every `secs`, q/Esc quits.
|
||||
fn tui_loop(app: &App, secs: f64, thresholds: &MonitorThresholds) -> Result<i32> {
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||
use ratatui::DefaultTerminal;
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let result = (|| -> Result<i32> {
|
||||
loop {
|
||||
let rows = collect(app, secs);
|
||||
for m in &rows {
|
||||
fire_alerts(app, m, thresholds);
|
||||
}
|
||||
terminal.draw(|f| {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(f.area());
|
||||
let title = Line::from(vec![
|
||||
Span::styled(
|
||||
" am monitor ",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{} managed process(es)", rows.len()),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]);
|
||||
f.render_widget(
|
||||
Block::default().borders(Borders::TOP).title(title),
|
||||
chunks[0],
|
||||
);
|
||||
let items: Vec<ListItem> = rows
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let state = if m.state == "dead" {
|
||||
Style::default().fg(Color::Red)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
"{:<20} pid {:>7} cpu {:>6.1}% rss {:>8.0} MB up {}",
|
||||
m.agent,
|
||||
m.pid,
|
||||
m.cpu_pct,
|
||||
m.mem_mb,
|
||||
human_uptime(m.uptime_s),
|
||||
),
|
||||
state,
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
f.render_widget(
|
||||
List::new(items).block(Block::default().borders(Borders::ALL)),
|
||||
chunks[1],
|
||||
);
|
||||
let hint = Line::from(Span::styled(
|
||||
" q / Esc: quit ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
f.render_widget(hint, chunks[2]);
|
||||
})?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(k) = event::read()? {
|
||||
if k.kind == KeyEventKind::Press
|
||||
&& matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
fn human_uptime(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_metric_computes_cpu_and_mem() {
|
||||
let before = RawReading {
|
||||
cpu_total_s: 10.0,
|
||||
rss_bytes: 2 * 1024 * 1024,
|
||||
};
|
||||
let after = RawReading {
|
||||
cpu_total_s: 12.0,
|
||||
rss_bytes: 4 * 1024 * 1024,
|
||||
};
|
||||
let m = to_metric("a", 42, Some(before), after, None, 2.0);
|
||||
assert_eq!(m.pid, 42);
|
||||
assert!((m.cpu_pct - 100.0).abs() < 1e-6, "{}", m.cpu_pct);
|
||||
assert!((m.mem_mb - 4.0).abs() < 1e-6);
|
||||
assert_eq!(m.state, "running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_metric_first_sample_zero_cpu() {
|
||||
let after = RawReading {
|
||||
cpu_total_s: 5.0,
|
||||
rss_bytes: 1024 * 1024,
|
||||
};
|
||||
let m = to_metric("a", 1, None, after, None, 2.0);
|
||||
assert_eq!(m.cpu_pct, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_metric_parses_uptime() {
|
||||
let now = chrono::Utc::now();
|
||||
let started = now - chrono::Duration::minutes(5);
|
||||
let after = RawReading {
|
||||
cpu_total_s: 1.0,
|
||||
rss_bytes: 1024 * 1024,
|
||||
};
|
||||
let m = to_metric("a", 1, None, after, Some(&started.to_rfc3339()), 0.0);
|
||||
assert!((260..=320).contains(&m.uptime_s), "{}", m.uptime_s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alerts_fire_on_thresholds() {
|
||||
let m = AgentMetric {
|
||||
agent: "a".into(),
|
||||
pid: 1,
|
||||
cpu_pct: 95.0,
|
||||
mem_mb: 2000.0,
|
||||
uptime_s: 0,
|
||||
state: "running".into(),
|
||||
};
|
||||
let t = MonitorThresholds {
|
||||
cpu_pct: Some(80.0),
|
||||
mem_mb: Some(1024),
|
||||
};
|
||||
let alerts = alerts_for(&m, &t);
|
||||
assert_eq!(alerts.len(), 2);
|
||||
// Below thresholds → nothing.
|
||||
let quiet = AgentMetric {
|
||||
cpu_pct: 10.0,
|
||||
mem_mb: 100.0,
|
||||
..m.clone()
|
||||
};
|
||||
assert!(alerts_for(&quiet, &t).is_empty());
|
||||
// 0 disables the threshold.
|
||||
let off = MonitorThresholds {
|
||||
cpu_pct: Some(0.0),
|
||||
mem_mb: Some(0),
|
||||
};
|
||||
assert!(alerts_for(&m, &off).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ps_time_formats() {
|
||||
assert!((parse_ps_time("01:30") - 90.0).abs() < 1e-9);
|
||||
assert!((parse_ps_time("1:02:03") - 3723.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! playbook: replay a saved playbook step by step (issue #51).
|
||||
//! Playbooks are created with 'am history <from>..<to> --save <file>'.
|
||||
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(app: &App, path: &Path, vars: &[String]) -> Result<i32> {
|
||||
let mut values = BTreeMap::new();
|
||||
for v in vars {
|
||||
let Some((k, val)) = v.split_once('=') else {
|
||||
anyhow::bail!("--var expects name=value, got '{v}'");
|
||||
};
|
||||
values.insert(k.trim().to_string(), val.trim().to_string());
|
||||
}
|
||||
let resolved = crate::playbook::resolve_dest(app, path);
|
||||
let used = if resolved.exists() { resolved } else { path.to_path_buf() };
|
||||
crate::playbook::run_playbook(app, &used, &values)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! plugins: list the event plugins and test one against a synthetic event
|
||||
//! (issue #75). 'am plugins --test <name>' is the CI hook: it exercises the
|
||||
//! JSON contract end to end and exits non-zero on failure.
|
||||
|
||||
use super::*;
|
||||
use crate::cli::PluginsArgs;
|
||||
use crate::plugins::{self, PluginStatus};
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, args: &PluginsArgs) -> Result<i32> {
|
||||
let dir = plugins::dir(app);
|
||||
let all = plugins::discover(app, &dir);
|
||||
|
||||
if let Some(name) = &args.test {
|
||||
let Some(plugin) = all.iter().find(|p| &p.name == name) else {
|
||||
anyhow::bail!(
|
||||
"unknown plugin '{name}' — 'am plugins' lists the plugins of {}",
|
||||
dir.display()
|
||||
);
|
||||
};
|
||||
// Synthetic event (a start, agent 'test') to exercise the contract.
|
||||
let event = crate::events::Event::now(crate::events::EventKind::Start)
|
||||
.with_agent("test")
|
||||
.with_args(vec!["--probe".to_string()]);
|
||||
let r = plugins::run_plugin(app, plugin, "on_start", &event);
|
||||
if app.json() {
|
||||
crate::output::print_json(&serde_json::json!({
|
||||
"name": plugin.name,
|
||||
"status": r.status.as_str(),
|
||||
"exit_code": r.exit_code,
|
||||
"duration_ms": r.duration_ms,
|
||||
"response": r.response,
|
||||
"stdout": r.stdout,
|
||||
"stderr": r.stderr,
|
||||
"error": r.error,
|
||||
}));
|
||||
} else {
|
||||
app.log.info(&format!(
|
||||
"plugin '{}' — {} in {}ms",
|
||||
plugin.name,
|
||||
r.status.as_str(),
|
||||
r.duration_ms
|
||||
));
|
||||
if let Some(code) = r.exit_code {
|
||||
app.log.info(&format!("exit code: {code}"));
|
||||
}
|
||||
if let Some(resp) = &r.response {
|
||||
if let Some(msg) = &resp.message {
|
||||
app.log.info(&format!("message: {msg}"));
|
||||
}
|
||||
if let Some(err) = &resp.error {
|
||||
app.log.warn(&format!("error: {err}"));
|
||||
}
|
||||
}
|
||||
if !r.stdout.trim().is_empty() {
|
||||
app.log.info(&format!("stdout: {}", r.stdout.trim()));
|
||||
}
|
||||
if !r.stderr.trim().is_empty() {
|
||||
app.log.warn(&format!("stderr: {}", r.stderr.trim()));
|
||||
}
|
||||
if let Some(e) = &r.error {
|
||||
app.log.warn(&format!("{e}"));
|
||||
}
|
||||
}
|
||||
return Ok(if r.status == PluginStatus::Ok { 0 } else { 1 });
|
||||
}
|
||||
|
||||
if app.json() {
|
||||
let items: Vec<serde_json::Value> = all
|
||||
.iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"name": p.name,
|
||||
"events": p.events,
|
||||
"timeout_secs": p.timeout.as_secs(),
|
||||
"script": p.file.display().to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
crate::output::print_json(&items);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if all.is_empty() {
|
||||
app.log.info(&format!(
|
||||
"no plugins in {} — copy one from examples/plugins/ (notify, ci-webhook)",
|
||||
dir.display()
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = crate::output::Table::new(vec!["PLUGIN", "EVENTS", "TIMEOUT", "SCRIPT"]);
|
||||
for p in &all {
|
||||
table.row(vec![
|
||||
p.name.clone(),
|
||||
p.events.join(", "),
|
||||
format!("{}s", p.timeout.as_secs()),
|
||||
p.file.display().to_string(),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+418
-8
@@ -69,18 +69,61 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
)?;
|
||||
// --model (issue #71): resolve against the local runtimes, then
|
||||
// transmit through the agent's declared channel (env var or flag).
|
||||
// --provider (issue #92): resolve from the LLM provider registry.
|
||||
let mut extra_args = extra_args;
|
||||
let mut extra_env = extra_env;
|
||||
if let Some(p) = opts.provider.as_deref() {
|
||||
let agent = require_agent(app, &target)?;
|
||||
let (a, e) = apply_provider(app, agent, Some(p), opts.model.as_deref())?
|
||||
.expect("a provider flag was given: apply_provider always resolves or errors");
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
} else if let Some(m) = opts.model.as_deref() {
|
||||
let agent = require_agent(app, &target)?;
|
||||
match apply_model(app, agent, m) {
|
||||
Ok((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
|
||||
Some((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
None => return Err(local_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||
let members = app.catalog.group_members(group);
|
||||
if members.is_empty() {
|
||||
bail!("unknown group '{group}'");
|
||||
}
|
||||
// Issue #57: --parallel launches every member back-to-back without
|
||||
// waiting (the OS spawns them concurrently); the default waits each
|
||||
// health check before the next member.
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, cwd)?;
|
||||
if !opts.parallel {
|
||||
// Issue #57: block until healthy before the next member.
|
||||
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
|
||||
}
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let agent = require_agent(app, &target)?;
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd)
|
||||
start_one(
|
||||
app,
|
||||
agent,
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
opts.notify,
|
||||
opts.background,
|
||||
opts.no_sandbox,
|
||||
cwd,
|
||||
)
|
||||
}
|
||||
|
||||
fn start_one(
|
||||
@@ -90,6 +133,7 @@ fn start_one(
|
||||
extra_env: &BTreeMap<String, String>,
|
||||
notify: bool,
|
||||
background: bool,
|
||||
no_sandbox: bool,
|
||||
cwd: Option<&std::path::Path>,
|
||||
) -> Result<i32> {
|
||||
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
||||
@@ -99,7 +143,10 @@ fn start_one(
|
||||
let current = std::env::current_dir().unwrap_or_default();
|
||||
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤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);
|
||||
}
|
||||
@@ -289,20 +336,77 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
||||
stop_one(app, m, force, timeout)?;
|
||||
}
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?;
|
||||
start_one(
|
||||
app,
|
||||
m,
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
opts.notify,
|
||||
true,
|
||||
opts.no_sandbox,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let agent = require_agent(app, &target)?;
|
||||
stop_one(app, agent, force, timeout)?;
|
||||
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None)
|
||||
start_one(
|
||||
app,
|
||||
agent,
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
opts.notify,
|
||||
opts.background,
|
||||
opts.no_sandbox,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// run: execute the agent command directly, no process management.
|
||||
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
target: &str,
|
||||
model: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
container: bool,
|
||||
no_sandbox: bool,
|
||||
extra: &[OsString],
|
||||
) -> Result<i32> {
|
||||
let agent = require_agent(app, target)?;
|
||||
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
|
||||
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
|
||||
// Issue #58: --container runs the agent inside its container profile.
|
||||
if container {
|
||||
return run_container(app, agent, extra);
|
||||
}
|
||||
let mut extra_args: Vec<String> = extra
|
||||
.iter()
|
||||
.map(|o| o.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
|
||||
// Issue #92: --provider resolves from the LLM provider registry (cloud);
|
||||
// --model alone keeps the local-runtimes-first behavior (issue #71) with
|
||||
// a cloud fallback when the model is unknown locally.
|
||||
if let Some(p) = provider {
|
||||
let (a, e) = apply_provider(app, agent, Some(p), model)?
|
||||
.expect("a provider flag was given: apply_provider always resolves or errors");
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
} else if let Some(m) = model {
|
||||
match apply_model(app, agent, m) {
|
||||
Ok((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
|
||||
Some((a, e)) => {
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
None => return Err(local_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would run {} {}",
|
||||
@@ -315,6 +419,9 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
// Issue #79: sandbox enforcement (command allowlist, cwd perimeter,
|
||||
// network policy) — refused attempts are journalized for audit.
|
||||
crate::sandbox::enforce(app, agent, &prog, &mut exec.env, no_sandbox)?;
|
||||
let status = Command::new(&prog)
|
||||
.args(&full_args)
|
||||
.envs(&exec.env)
|
||||
@@ -334,11 +441,174 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Issue #58: run the agent inside its container profile. Detects the
|
||||
/// runtime (podman > docker), mounts state/log dirs and the profile
|
||||
/// volumes/network/env, then execs the container in the foreground.
|
||||
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
|
||||
let Some(spec) = &agent.container else {
|
||||
bail!(
|
||||
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
|
||||
agent.name
|
||||
);
|
||||
};
|
||||
let Some(runtime) = crate::automation::detect_runtime() else {
|
||||
bail!(
|
||||
"no container runtime found (docker or podman) — install one or drop --container"
|
||||
);
|
||||
};
|
||||
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
|
||||
let log_dir = &app.paths.log_dir;
|
||||
let mut cmd = crate::automation::container_command(
|
||||
&runtime,
|
||||
&agent.name,
|
||||
spec,
|
||||
state_dir,
|
||||
log_dir,
|
||||
)?;
|
||||
// The agent's run command + extra args become the container command.
|
||||
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
|
||||
cmd.push(exec.program);
|
||||
cmd.extend(exec.args.iter().cloned());
|
||||
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would run: {}", cmd.join(" ")));
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!(
|
||||
"running '{}' in a container ({runtime})",
|
||||
agent.name
|
||||
));
|
||||
let status = Command::new(&cmd[0])
|
||||
.args(&cmd[1..])
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
|
||||
let code = status.code().unwrap_or(1);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason(format!("container ({runtime})"))
|
||||
.with_args(cmd.clone())
|
||||
.with_exit_code(code),
|
||||
);
|
||||
let _ = app.state.touch(&agent.name);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Current directory as a string (None when unavailable).
|
||||
fn cwd_string() -> Option<String> {
|
||||
std::env::current_dir().ok().map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Resolve --model against the local runtimes and build the transmission
|
||||
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
|
||||
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
|
||||
/// model is an explicit error listing the available runtimes.
|
||||
pub fn apply_model(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
model: &str,
|
||||
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
|
||||
let inventory = crate::models::detect(app);
|
||||
if inventory.find(model).is_none() {
|
||||
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
|
||||
let hint = if known.is_empty() {
|
||||
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
|
||||
} else {
|
||||
format!("available models: {} — run 'am models' for details", known.join(", "))
|
||||
};
|
||||
bail!("model '{model}' not found on any local runtime ({hint})");
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
let mut env = BTreeMap::new();
|
||||
if let Some(var) = &agent.model_env {
|
||||
env.insert(var.clone(), model.to_string());
|
||||
app.log.verbose(&format!(
|
||||
"model '{model}' transmitted to {} via {var}",
|
||||
agent.name
|
||||
));
|
||||
} else if let Some(flag) = &agent.model_arg {
|
||||
args.push(flag.clone());
|
||||
args.push(model.to_string());
|
||||
app.log.verbose(&format!(
|
||||
"model '{model}' transmitted to {} via {flag}",
|
||||
agent.name
|
||||
));
|
||||
} else {
|
||||
// Generic fallback: many local agents read AM_MODEL; the config can
|
||||
// pin a specific channel (model_env / model_arg) instead.
|
||||
env.insert("AM_MODEL".to_string(), model.to_string());
|
||||
app.log.info(&format!(
|
||||
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
|
||||
agent.name
|
||||
));
|
||||
}
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// Issue #92: resolve `--provider`/`--model` against the LLM provider
|
||||
/// registry (cloud) and transmit through the agent's declared channels:
|
||||
/// `model_env` / `model_arg`, or the `config.env_map` slots (issue #91).
|
||||
/// The token travels as the `@secret` reference resolved by
|
||||
/// resolve_env_secrets — never on the command line. Returns None when no
|
||||
/// provider is configured at all (the caller falls back to the
|
||||
/// local-runtime path).
|
||||
fn apply_provider(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Option<(Vec<String>, BTreeMap<String, String>)>> {
|
||||
let Some((pname, _def, resolved_model)) = crate::providers::resolve_for(
|
||||
&app.config,
|
||||
crate::agent_config::provider_pref(agent),
|
||||
agent.model.as_deref(),
|
||||
provider,
|
||||
model,
|
||||
) else {
|
||||
if provider.is_some() {
|
||||
let known = crate::providers::names(&app.config);
|
||||
let hint = if known.is_empty() {
|
||||
"aucun (am providers add <nom> --base-url <url>)".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
};
|
||||
bail!(crate::tr_fmt!(
|
||||
"provider '{}' inconnu — providers enregistrés: {}",
|
||||
provider.unwrap_or(""),
|
||||
hint
|
||||
));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
||||
// env_map resolved against THIS provider (api_key stays @secret).
|
||||
crate::agent_config::apply_env_map_resolved(
|
||||
&mut env,
|
||||
agent,
|
||||
&app.config,
|
||||
Some(pname),
|
||||
resolved_model,
|
||||
);
|
||||
// The model through the agent's declared channel (model_env > model_arg);
|
||||
// otherwise the env_map "model" slot already carried it.
|
||||
if let Some(m) = resolved_model {
|
||||
if let Some(var) = &agent.model_env {
|
||||
env.insert(var.clone(), m.to_string());
|
||||
} else if let Some(flag) = &agent.model_arg {
|
||||
args.push(flag.clone());
|
||||
args.push(m.to_string());
|
||||
}
|
||||
}
|
||||
app.log.info(&crate::tr_fmt!(
|
||||
"provider {} (modèle {}) au lancement de {}",
|
||||
pname,
|
||||
resolved_model.unwrap_or("-"),
|
||||
agent.name
|
||||
));
|
||||
Ok(Some((args, env)))
|
||||
}
|
||||
|
||||
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
|
||||
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
||||
@@ -348,3 +618,143 @@ fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
.num_seconds();
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AgentConfig, AgentDef, Config};
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(providers_yaml: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
crate::app::App::from_cli(cli).unwrap()
|
||||
}
|
||||
|
||||
fn agent(model_env: bool, model_arg: bool) -> AgentDef {
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
|
||||
env_map.insert("model".to_string(), "MY_MODEL".to_string());
|
||||
env_map.insert("base_url".to_string(), "MY_URL".to_string());
|
||||
AgentDef {
|
||||
name: "demo".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
category: None,
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("demo".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: model_env.then(|| "MY_MODEL".to_string()),
|
||||
model_arg: model_arg.then(|| "--model".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
config: Some(AgentConfig {
|
||||
env_map,
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
}),
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
const OPENAI: &str = " default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n";
|
||||
|
||||
#[test]
|
||||
fn apply_provider_resolves_and_never_leaks_the_token() {
|
||||
let app = test_app(OPENAI);
|
||||
let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// The token slot is the @secret reference — never a value.
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_uses_the_declared_model_channel() {
|
||||
let app = test_app(OPENAI);
|
||||
// model_env wins over the env_map slot.
|
||||
let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert!(args.is_empty());
|
||||
// model_arg is pushed on the command line.
|
||||
let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_unknown_is_an_error_listing_the_registry() {
|
||||
let app = test_app(OPENAI);
|
||||
let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("nope"), "{msg}");
|
||||
assert!(msg.contains("openai"), "registry list missing: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_falls_back_to_the_embedded_default_provider() {
|
||||
// The embedded default catalog ships providers (anthropic★ since
|
||||
// issue #90): even an empty user config resolves through the cloud.
|
||||
let app = test_app("");
|
||||
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x"))
|
||||
.unwrap()
|
||||
.expect("the embedded default provider resolves");
|
||||
// Model "x" is unknown: the env_map model slot is skipped, but the
|
||||
// provider slot is wired.
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1");
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_flag_falls_back_to_the_cloud_registry() {
|
||||
let app = test_app(OPENAI);
|
||||
// apply_model bails (no local runtime in the test env) → the caller
|
||||
// falls back to apply_provider with the default provider.
|
||||
let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err();
|
||||
assert!(local.to_string().contains("not found on any local runtime"));
|
||||
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_parses_the_provider_registry_and_agents() {
|
||||
let _cfg: Config = serde_yaml::from_str(&format!(
|
||||
"version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! schedule: plan am commands (axe 6, issue #56). Schedules live in
|
||||
//! state.json; a daily OS-level task (crontab / Task Scheduler) points back
|
||||
//! at 'am schedule-run <id>' which executes the stored command, records the
|
||||
//! result and notifies on failure.
|
||||
|
||||
use super::*;
|
||||
use crate::automation;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::state::ScheduleEntry;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, action: &str, command: Vec<String>, at: Option<&str>) -> Result<i32> {
|
||||
match action {
|
||||
"add" => add(app, command, at),
|
||||
"list" => list(app),
|
||||
"remove" => {
|
||||
let id = command
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("schedule remove requires an id"))?;
|
||||
remove(app, id)
|
||||
}
|
||||
"run" => {
|
||||
let id = command
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("schedule-run requires an id"))?;
|
||||
run_one(app, id)
|
||||
}
|
||||
_ => bail!("usage: am schedule add <cmd...> | am schedule list | am schedule remove <id> | am schedule run <id>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_name(id: &str) -> String {
|
||||
format!("am-schedule-{id}")
|
||||
}
|
||||
|
||||
/// Register the OS-level daily task (crontab on unix, schtasks on Windows).
|
||||
/// Best effort: the schedule stays in state.json even if the OS layer
|
||||
/// cannot be reached (warned, not fatal).
|
||||
fn register_os_task(app: &App, entry: &ScheduleEntry) {
|
||||
let am_bin = automation::am_bin_quoted();
|
||||
let id = &entry.id;
|
||||
if cfg!(windows) {
|
||||
let status = automation::schtasks_daily(&entry.at, &am_bin, id, &task_name(id))
|
||||
.status()
|
||||
.unwrap_or_default();
|
||||
if !status.success() {
|
||||
app.log
|
||||
.warn("schtasks failed — the schedule is stored but not registered at OS level");
|
||||
}
|
||||
} else {
|
||||
// Append to the user crontab (dedup first).
|
||||
let existing = Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
if existing.contains(&format!("schedule-run {id}")) {
|
||||
return;
|
||||
}
|
||||
let line = automation::crontab_line(&entry.at, &am_bin, id);
|
||||
let mut next = existing.trim_end().to_string();
|
||||
next.push('\n');
|
||||
next.push_str(&line);
|
||||
next.push('\n');
|
||||
let mut child = Command::new("crontab")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("crontab spawn");
|
||||
use std::io::Write;
|
||||
let _ = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.map(|s| s.write_all(next.as_bytes()));
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn add(app: &App, command: Vec<String>, at: Option<&str>) -> Result<i32> {
|
||||
if command.is_empty() {
|
||||
bail!("schedule add requires a command — ex: am schedule add update --all");
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would schedule: {}", command.join(" ")));
|
||||
return Ok(0);
|
||||
}
|
||||
let id = crate::repl::session_id();
|
||||
let entry = ScheduleEntry {
|
||||
id,
|
||||
cadence: "daily".to_string(),
|
||||
command,
|
||||
at: at.unwrap_or("08:00").to_string(),
|
||||
created_at: crate::installers::now_rfc3339(),
|
||||
last_run: None,
|
||||
last_exit: None,
|
||||
};
|
||||
let mut sf = app.state.load()?;
|
||||
sf.schedules.push(entry.clone());
|
||||
app.state.save(&sf)?;
|
||||
register_os_task(app, &entry);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent("schedule".to_string())
|
||||
.with_reason(format!("schedule add {} ({})", entry.id, entry.at)),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"scheduled daily at {}: {} (id {})",
|
||||
entry.at,
|
||||
entry.command.join(" "),
|
||||
entry.id
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let sf = app.state.load()?;
|
||||
if sf.schedules.is_empty() {
|
||||
app.log.info("no schedule — 'am schedule add <cmd>' to create one (ex: am schedule add update --all)");
|
||||
return Ok(0);
|
||||
}
|
||||
println!("schedules:");
|
||||
for e in &sf.schedules {
|
||||
let last = e
|
||||
.last_run
|
||||
.as_deref()
|
||||
.map(|t| {
|
||||
e.last_exit
|
||||
.map(|c| format!("{t} (exit {c})"))
|
||||
.unwrap_or_else(|| t.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
println!(
|
||||
" {} {} {} cmd: {} last: {}",
|
||||
e.id,
|
||||
e.cadence,
|
||||
e.at,
|
||||
e.command.join(" "),
|
||||
last
|
||||
);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn remove(app: &App, id: &str) -> Result<i32> {
|
||||
let mut sf = app.state.load()?;
|
||||
let before = sf.schedules.len();
|
||||
sf.schedules.retain(|e| e.id != id);
|
||||
if sf.schedules.len() == before {
|
||||
bail!("unknown schedule '{id}' — 'am schedule list' shows them");
|
||||
}
|
||||
app.state.save(&sf)?;
|
||||
// Best-effort OS cleanup.
|
||||
if cfg!(windows) {
|
||||
let _ = Command::new("schtasks")
|
||||
.args(["/delete", "/tn", &task_name(id), "/f"])
|
||||
.status();
|
||||
} else {
|
||||
let existing = Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
let cleaned: Vec<&str> = existing
|
||||
.lines()
|
||||
.filter(|l| !l.contains(&format!("schedule-run {id}")))
|
||||
.collect();
|
||||
let text = format!("{}\n", cleaned.join("\n"));
|
||||
let mut child = Command::new("crontab")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("crontab spawn");
|
||||
use std::io::Write;
|
||||
let _ = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.map(|s| s.write_all(text.as_bytes()));
|
||||
let _ = child.wait();
|
||||
}
|
||||
app.log.success(&format!("schedule '{id}' removed"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Execute one schedule (invoked by the OS task). Records last_run /
|
||||
/// last_exit, emits a Schedule event and notifies on failure.
|
||||
fn run_one(app: &App, id: &str) -> Result<i32> {
|
||||
let sf = app.state.load()?;
|
||||
let entry = sf
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("unknown schedule '{id}'"))?;
|
||||
app.log.info(&format!("running schedule {id}: {}", entry.command.join(" ")));
|
||||
let code = automation::schedule_run_command(app, &entry)?;
|
||||
// Record the outcome.
|
||||
let mut sf = app.state.load()?;
|
||||
if let Some(e) = sf.schedules.iter_mut().find(|e| e.id == id) {
|
||||
e.last_run = Some(crate::installers::now_rfc3339());
|
||||
e.last_exit = Some(code);
|
||||
}
|
||||
app.state.save(&sf)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent("schedule".to_string())
|
||||
.with_exit_code(code)
|
||||
.with_reason(format!("schedule {id}: {}", entry.command.join(" "))),
|
||||
);
|
||||
if code == 0 {
|
||||
app.log.success(&format!("schedule {id} completed"));
|
||||
} else {
|
||||
app.log.error(&format!(
|
||||
"schedule {id} FAILED (exit {code}) — command: {}",
|
||||
entry.command.join(" ")
|
||||
));
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
@@ -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__" {
|
||||
|
||||
@@ -109,16 +109,22 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
let scheduled = schedule_locked(&locked);
|
||||
if !scheduled.is_empty() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
"{} {}",
|
||||
"locked:".yellow(),
|
||||
scheduled.len()
|
||||
crate::tr_fmt!(
|
||||
"{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
scheduled.len()
|
||||
)
|
||||
);
|
||||
}
|
||||
if scheduled.len() != locked.len() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
"{} {}",
|
||||
"deferred:".cyan(),
|
||||
locked.len() - scheduled.len()
|
||||
crate::tr_fmt!(
|
||||
"{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
locked.len() - scheduled.len()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! service: register an agent as a system service with autostart (issue #55).
|
||||
//! systemd user unit on Linux, launchd LaunchAgent on macOS, Task Scheduler
|
||||
//! on Windows. Logs go through the existing 'am start --background' runner.
|
||||
|
||||
use super::*;
|
||||
use crate::automation;
|
||||
use crate::events::{Event, EventKind};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, action: &str, agent: Option<&str>, autostart: bool) -> Result<i32> {
|
||||
match action {
|
||||
"install" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service install requires an agent name"))?;
|
||||
install(app, name, autostart)
|
||||
}
|
||||
"uninstall" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service uninstall requires an agent name"))?;
|
||||
uninstall(app, name)
|
||||
}
|
||||
"status" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service status requires an agent name"))?;
|
||||
status(app, name)
|
||||
}
|
||||
_ => bail!("usage: am service install|uninstall|status <agent> [--autostart]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_path(agent: &str) -> PathBuf {
|
||||
if cfg!(target_os = "linux") {
|
||||
automation::systemd_unit_path(agent)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
automation::launchd_path(agent)
|
||||
} else {
|
||||
// Windows: no file — the task lives in the Task Scheduler.
|
||||
PathBuf::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn install(app: &App, agent: &str, autostart: bool) -> Result<i32> {
|
||||
require_agent(app, agent)?;
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would install '{agent}' as a system service (autostart: {autostart})"));
|
||||
return Ok(0);
|
||||
}
|
||||
let am_bin = automation::am_bin_quoted();
|
||||
let mut wrote = String::new();
|
||||
if cfg!(target_os = "linux") {
|
||||
let path = automation::systemd_unit_path(agent);
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&path, automation::systemd_unit(agent, &am_bin))
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
wrote = path.display().to_string();
|
||||
// Enable + start the user unit.
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
if autostart {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "enable", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
}
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "start", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let path = automation::launchd_path(agent);
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&path, automation::launchd_plist(agent, &am_bin))
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
wrote = path.display().to_string();
|
||||
if autostart {
|
||||
let _ = Command::new("launchctl")
|
||||
.args(["load", path.to_str().unwrap_or_default()])
|
||||
.status();
|
||||
}
|
||||
} else {
|
||||
// Windows: prefer a logon task. /sc onlogon needs admin on some
|
||||
// setups; fall back to the Startup folder (no admin required).
|
||||
let task = automation::windows_task_name(agent);
|
||||
let am_bin = std::env::current_exe().unwrap_or_default();
|
||||
let tr = format!("\"{}\" start {} --background --notify", am_bin.display(), agent);
|
||||
let status = Command::new("schtasks")
|
||||
.args(["/create", "/tn", &task, "/sc", "onlogon", "/tr", &tr, "/f"])
|
||||
.status()?;
|
||||
if status.success() {
|
||||
wrote = format!("Task Scheduler: {task}");
|
||||
} else {
|
||||
let startup = crate::config::home_dir()
|
||||
.map(|h| {
|
||||
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = startup.join(format!("am-{agent}.cmd"));
|
||||
if let Some(p) = script.parent() {
|
||||
std::fs::create_dir_all(p)
|
||||
.with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&script, format!("@echo off\r\nstart \"\" /b {tr}\r\n"))
|
||||
.with_context(|| format!("cannot write {}", script.display()))?;
|
||||
wrote = format!("Startup folder: {}", script.display());
|
||||
}
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent(agent.to_string())
|
||||
.with_reason(format!("service install (autostart: {autostart})")),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"service '{agent}' installed{} ({wrote})",
|
||||
if autostart { " with autostart" } else { "" }
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn uninstall(app: &App, agent: &str) -> Result<i32> {
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove the service for '{agent}'"));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut removed = String::new();
|
||||
if cfg!(target_os = "linux") {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
if automation::systemd_unit_path(agent).exists() {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
std::fs::remove_file(automation::systemd_unit_path(agent))?;
|
||||
}
|
||||
let _ = Command::new("systemctl").args(["--user", "daemon-reload"]).status();
|
||||
removed = "systemd unit removed".to_string();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let path = automation::launchd_path(agent);
|
||||
if path.exists() {
|
||||
let _ = Command::new("launchctl").args(["unload", path.to_str().unwrap_or_default()]).status();
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
removed = "launchd plist removed".to_string();
|
||||
} else {
|
||||
let task = automation::windows_task_name(agent);
|
||||
let _ = Command::new("schtasks")
|
||||
.args(["/delete", "/tn", &task, "/f"])
|
||||
.status();
|
||||
let mut parts = vec![format!("Task Scheduler: {task}")];
|
||||
// Also remove the Startup folder fallback if present.
|
||||
if let Some(h) = crate::config::home_dir() {
|
||||
let script = h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
.join(format!("am-{agent}.cmd"));
|
||||
if script.exists() {
|
||||
std::fs::remove_file(&script).with_context(|| {
|
||||
format!("cannot remove {}", script.display())
|
||||
})?;
|
||||
parts.push("Startup script removed".to_string());
|
||||
}
|
||||
}
|
||||
removed = parts.join(" · ");
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent(agent.to_string())
|
||||
.with_reason("service uninstall"),
|
||||
);
|
||||
app.log.success(&format!("service '{agent}' removed ({removed})"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn status(app: &App, agent: &str) -> Result<i32> {
|
||||
let mut installed = false;
|
||||
if cfg!(target_os = "linux") {
|
||||
installed = automation::systemd_unit_path(agent).exists();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
installed = automation::launchd_path(agent).exists();
|
||||
} else {
|
||||
let task = automation::windows_task_name(agent);
|
||||
let status = Command::new("schtasks")
|
||||
.args(["/query", "/tn", &task])
|
||||
.status()
|
||||
.unwrap_or_default();
|
||||
installed = status.success();
|
||||
if !installed {
|
||||
// Startup folder fallback?
|
||||
installed = crate::config::home_dir()
|
||||
.map(|h| {
|
||||
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
.join(format!("am-{agent}.cmd"))
|
||||
.exists()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
if installed {
|
||||
println!("service '{agent}': installed");
|
||||
Ok(0)
|
||||
} else {
|
||||
app.log.info(&format!("service '{agent}': not installed"));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,22 @@ use super::*;
|
||||
use crate::output::{print_json, status_style, Table};
|
||||
use crate::sessions::{self, SessionRecord};
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionRow {
|
||||
id: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
status: String,
|
||||
pid: Option<u32>,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
started_at: String,
|
||||
ended_at: Option<String>,
|
||||
duration_s: Option<u64>,
|
||||
exit_code: Option<i32>,
|
||||
log: Option<String>,
|
||||
pub struct SessionRow {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub agent: Option<String>,
|
||||
pub status: String,
|
||||
pub pid: Option<u32>,
|
||||
pub cwd: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub duration_s: Option<u64>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub log: Option<String>,
|
||||
}
|
||||
|
||||
fn row_of(r: &SessionRecord) -> SessionRow {
|
||||
@@ -38,21 +39,14 @@ fn row_of(r: &SessionRecord) -> SessionRow {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
/// Rows for the web API (issue #54): same shape as `am sessions --json`.
|
||||
/// Unlike `run`, it never reconciles the registry (the web is read-only).
|
||||
pub fn list_rows(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
project: Option<&str>,
|
||||
status: Option<&str>,
|
||||
show: Option<&str>,
|
||||
resume: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
if let Some(id) = show {
|
||||
return show_one(app, id);
|
||||
}
|
||||
if let Some(id) = resume {
|
||||
return resume_session(app, id);
|
||||
}
|
||||
sessions::reconcile(app)?;
|
||||
) -> Result<Vec<SessionRow>> {
|
||||
let sf = sessions::load(app)?;
|
||||
let mut rows: Vec<SessionRow> = sf
|
||||
.sessions
|
||||
@@ -63,6 +57,34 @@ pub fn run(
|
||||
.map(row_of)
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
project: Option<&str>,
|
||||
status: Option<&str>,
|
||||
show: Option<&str>,
|
||||
resume: Option<&str>,
|
||||
export: Option<&str>,
|
||||
output: Option<&str>,
|
||||
retention: Option<u64>,
|
||||
) -> Result<i32> {
|
||||
if let Some(id) = export {
|
||||
return export_one(app, id, output);
|
||||
}
|
||||
if let Some(days) = retention {
|
||||
return purge(app, days);
|
||||
}
|
||||
if let Some(id) = show {
|
||||
return show_one(app, id);
|
||||
}
|
||||
if let Some(id) = resume {
|
||||
return resume_session(app, id);
|
||||
}
|
||||
sessions::reconcile(app)?;
|
||||
let rows = list_rows(app, agent, project, status)?;
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
@@ -181,6 +203,57 @@ fn short_id(id: &str) -> String {
|
||||
id.chars().take(15).collect()
|
||||
}
|
||||
|
||||
/// Issue #53: 'am sessions export <id> [--output <file>]' — write a
|
||||
/// complete, self-contained JSON export (metadata, commands, log excerpt)
|
||||
/// plus a human-readable rendering. Default destination:
|
||||
/// ./am-session-<id>.json in the current directory.
|
||||
fn export_one(app: &App, id: &str, output: Option<&str>) -> Result<i32> {
|
||||
let export = sessions::export_session(app, id)?;
|
||||
let dest = match output {
|
||||
Some(o) => PathBuf::from(o),
|
||||
None => std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(format!("am-session-{}.json", export.session.id)),
|
||||
};
|
||||
let text = serde_json::to_string_pretty(&export)?;
|
||||
std::fs::write(&dest, text).map_err(|e| {
|
||||
anyhow::anyhow!("cannot write export {}: {e}", dest.display())
|
||||
})?;
|
||||
app.log.success(&format!(
|
||||
"exported session {} ({} command(s), {} log bytes) to {}",
|
||||
export.session.id,
|
||||
export.commands.len(),
|
||||
export.log_excerpt.len(),
|
||||
dest.display()
|
||||
));
|
||||
app.log.info(&format!(
|
||||
"JSON complet + rendu lisible ('readable') — compatible archivage manuel / am import"
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Issue #53: 'am sessions --retention <days>' — purge finished sessions
|
||||
/// older than the threshold. The primary event journal is untouched; the
|
||||
/// purge is journaled as EventKind::Prune.
|
||||
fn purge(app: &App, days: u64) -> Result<i32> {
|
||||
if days == 0 {
|
||||
bail!("retention must be at least 1 day");
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would purge sessions finished more than {days} days ago"));
|
||||
return Ok(0);
|
||||
}
|
||||
let purged = sessions::purge_expired(app, days)?;
|
||||
match purged {
|
||||
0 => app.log.info(&format!(
|
||||
"no session finished more than {days} days ago — nothing to purge"
|
||||
)),
|
||||
n => app.log.success(&format!("purged {n} session(s) older than {days} days")),
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
|
||||
@@ -119,12 +119,25 @@ pub fn report_md(app: &App, days: u32) -> String {
|
||||
}
|
||||
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
|
||||
let top_rows = top(app, None).unwrap_or_default();
|
||||
let mut md = format!(
|
||||
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
|
||||
duration
|
||||
);
|
||||
// Issue #74: the report follows the active language.
|
||||
let md = if app.lang().is_en() {
|
||||
format!(
|
||||
"# agent-manager report — last {days} days\n\n- Launches: {total_launches}\n- Failures: {failures}\n- Changes (install/update/uninstall): {changes}\n- Cumulative duration: {}s\n- Recorded sessions: {sessions}\n",
|
||||
duration
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
|
||||
duration
|
||||
)
|
||||
};
|
||||
let mut md = md;
|
||||
if !top_rows.is_empty() {
|
||||
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
|
||||
md.push_str(if app.lang().is_en() {
|
||||
"\n## Top agents\n\n| Agent | Launches | Duration | Failures |\n|---|---|---|---|\n"
|
||||
} else {
|
||||
"\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n"
|
||||
});
|
||||
for r in &top_rows {
|
||||
md.push_str(&format!(
|
||||
"| {} | {} | {}s | {} |\n",
|
||||
@@ -132,10 +145,30 @@ pub fn report_md(app: &App, days: u32) -> String {
|
||||
));
|
||||
}
|
||||
}
|
||||
// Issue #49: cost section when usage was recorded.
|
||||
if let Ok(rows) = crate::costs::aggregate(app, Some(&format!("{days}d"))) {
|
||||
if !rows.is_empty() {
|
||||
let t = crate::costs::totals(&rows);
|
||||
md.push_str(&format!(
|
||||
"{}\n\n- {}: {}\n- {}: {} in / {} out\n- {}: ${:.4}\n",
|
||||
if app.lang().is_en() { "\n## Estimated costs" } else { "\n## Coûts estimés" },
|
||||
if app.lang().is_en() { "Billed sessions" } else { "Sessions facturées" },
|
||||
t.sessions,
|
||||
if app.lang().is_en() { "Tokens" } else { "Tokens" },
|
||||
t.tokens_in,
|
||||
t.tokens_out,
|
||||
if app.lang().is_en() { "Estimated cost" } else { "Coût estimé" },
|
||||
t.cost_usd
|
||||
));
|
||||
}
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
|
||||
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>, costs: bool) -> Result<i32> {
|
||||
if costs {
|
||||
return run_costs(app, period);
|
||||
}
|
||||
let rows = aggregate(app, agent, period)?;
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
@@ -201,6 +234,40 @@ pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am stats --costs : token usage and estimated spend (issue #49).
|
||||
pub fn run_costs(app: &App, period: Option<&str>) -> Result<i32> {
|
||||
let rows = crate::costs::aggregate(app, period)?;
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
app.log
|
||||
.info("no cost recorded yet — agents exposing their usage (claude-code) are priced at session end");
|
||||
return Ok(0);
|
||||
}
|
||||
let total = crate::costs::totals(&rows);
|
||||
let mut table = Table::new(vec!["AGENT", "SESSIONS", "TOKENS IN", "TOKENS OUT", "COST $"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
r.agent.clone(),
|
||||
r.sessions.to_string(),
|
||||
r.tokens_in.to_string(),
|
||||
r.tokens_out.to_string(),
|
||||
format!("{:.4}", r.cost_usd),
|
||||
]);
|
||||
}
|
||||
table.row(vec![
|
||||
"TOTAL".to_string(),
|
||||
total.sessions.to_string(),
|
||||
total.tokens_in.to_string(),
|
||||
total.tokens_out.to_string(),
|
||||
format!("{:.4}", total.cost_usd),
|
||||
]);
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am report : write the markdown digest (issue #37).
|
||||
pub fn run_report(
|
||||
app: &App,
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! suggest: recommend agents for a natural-language request (issue #61,
|
||||
//! axe 7). Reuses the catalog fuzzy scoring (name/title/description/
|
||||
//! category/tags) and boosts the ranking with real usage from the event
|
||||
//! journal (launch counts and durations), with a short justification.
|
||||
|
||||
use super::*;
|
||||
use crate::output::print_json;
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Top N suggestions shown.
|
||||
const TOP_N: usize = 5;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SuggestRow {
|
||||
name: String,
|
||||
display_name: String,
|
||||
category: Option<String>,
|
||||
/// Combined score (fuzzy * usage boost).
|
||||
score: f64,
|
||||
/// Why this agent was suggested.
|
||||
reason: String,
|
||||
/// Real usage in the journal (when available).
|
||||
launches: u64,
|
||||
total_duration_s: u64,
|
||||
}
|
||||
|
||||
pub fn run(app: &App, query: &str) -> Result<i32> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
anyhow::bail!("empty query — describe what you need, e.g. 'am suggest un agent pour du Python'");
|
||||
}
|
||||
// 1. Fuzzy scoring over the catalog (name x3, title x2, rest x1).
|
||||
let scored: Vec<(u32, &AgentDef)> = app
|
||||
.catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter(|a| !a.hidden)
|
||||
.filter_map(|a| {
|
||||
let kw = query.to_lowercase();
|
||||
let name = a.name.to_lowercase();
|
||||
let title = a.title().to_lowercase();
|
||||
let rest = format!(
|
||||
"{} {} {}",
|
||||
a.description.as_deref().unwrap_or("").to_lowercase(),
|
||||
a.category.as_deref().unwrap_or("").to_lowercase(),
|
||||
a.tags.join(" ").to_lowercase()
|
||||
);
|
||||
let score = score_token(&kw, &name)
|
||||
.saturating_mul(3)
|
||||
.saturating_add(score_token(&kw, &title).saturating_mul(2))
|
||||
.saturating_add(score_token(&kw, &rest));
|
||||
(score > 0).then_some((score, a))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 2. Real usage boost from the journal (stats_cmd aggregates launches
|
||||
// and durations per agent).
|
||||
let usage = crate::commands::stats_cmd::aggregate(app, None, None).unwrap_or_default();
|
||||
let launches: BTreeMap<&str, u64> = usage
|
||||
.iter()
|
||||
.map(|s| (s.agent.as_str(), s.launches))
|
||||
.collect();
|
||||
let duration: BTreeMap<&str, u64> = usage
|
||||
.iter()
|
||||
.map(|s| (s.agent.as_str(), s.total_duration_s))
|
||||
.collect();
|
||||
|
||||
let mut rows: Vec<SuggestRow> = scored
|
||||
.iter()
|
||||
.map(|(score, a)| {
|
||||
let l = launches.get(a.name.as_str()).copied().unwrap_or(0);
|
||||
let d = duration.get(a.name.as_str()).copied().unwrap_or(0);
|
||||
// Boost: up to +50 % for the most used agents (log scale).
|
||||
let boost = if l > 0 {
|
||||
1.0 + 0.5 * (1.0 + (l as f64).ln()).min(2.0) / 2.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let final_score = (*score as f64) * boost;
|
||||
let reason = justify(a, l, d);
|
||||
SuggestRow {
|
||||
name: a.name.clone(),
|
||||
display_name: a.title().to_string(),
|
||||
category: a.category.clone(),
|
||||
score: final_score,
|
||||
reason,
|
||||
launches: l,
|
||||
total_duration_s: d,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.name.cmp(&b.name))
|
||||
});
|
||||
rows.truncate(TOP_N);
|
||||
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
let suggestions = app.catalog.suggest(query);
|
||||
if suggestions.is_empty() {
|
||||
app.log.info(&format!(
|
||||
"no agent matches '{query}' — try 'am list --all' to browse the catalog"
|
||||
));
|
||||
} else {
|
||||
app.log.info(&format!(
|
||||
"{} — {} : {} ?",
|
||||
crate::tr_fmt!("no agent matches '{}'", query),
|
||||
crate::i18n::tr("vouliez-vous dire"),
|
||||
suggestions.join(", ")
|
||||
));
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
println!(
|
||||
"{} \"{query}\":",
|
||||
crate::i18n::tr("suggestions for")
|
||||
);
|
||||
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
r.display_name.clone(),
|
||||
r.category.clone().unwrap_or_else(|| "—".to_string()),
|
||||
r.reason.clone(),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
if rows.len() < scored.len() {
|
||||
println!(
|
||||
"\n ({} other match(es) not shown — 'am search {}' lists them)",
|
||||
scored.len() - rows.len(),
|
||||
query
|
||||
);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Short justification: matched field(s) + real usage when available.
|
||||
fn justify(a: &AgentDef, launches: u64, duration_s: u64) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(c) = &a.category {
|
||||
parts.push(format!("catégorie {c}"));
|
||||
}
|
||||
if !a.tags.is_empty() {
|
||||
parts.push(format!("tags: {}", a.tags.join(", ")));
|
||||
}
|
||||
if launches > 0 {
|
||||
parts.push(format!("{launches} lancement(s)"));
|
||||
if duration_s >= 3600 {
|
||||
parts.push(format!("{}h", duration_s / 3600));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
a.description
|
||||
.as_deref()
|
||||
.map(|d| {
|
||||
let mut d = d.to_string();
|
||||
d.truncate(60);
|
||||
d
|
||||
})
|
||||
.unwrap_or_else(|| "correspond à la requête".to_string())
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuse the catalog scoring helper (exported from catalog.rs).
|
||||
fn score_token(keyword: &str, hay: &str) -> u32 {
|
||||
crate::catalog::score_keyword(keyword, hay)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent(name: &str, desc: &str, tags: &[&str]) -> AgentDef {
|
||||
AgentDef {
|
||||
name: name.to_string(),
|
||||
display_name: None,
|
||||
description: Some(desc.to_string()),
|
||||
category: Some("coding-agent".to_string()),
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some(name.to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: None,
|
||||
model_arg: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
config: None,
|
||||
sandbox: None,
|
||||
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoring_prefers_relevant_agents() {
|
||||
let a = agent("python-mate", "assistant python", &["python"]);
|
||||
let b = agent("rust-mate", "assistant rust", &["rust"]);
|
||||
let catalog = crate::catalog::Catalog::new(
|
||||
vec![a, b],
|
||||
BTreeMap::new(),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
let kw = "python";
|
||||
let mut scored: Vec<(&str, u32)> = catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
let s = score_token(kw, &x.name.to_lowercase())
|
||||
.saturating_mul(3)
|
||||
.saturating_add(score_token(kw, &x.title().to_lowercase()).saturating_mul(2))
|
||||
.saturating_add(score_token(
|
||||
kw,
|
||||
&format!("{} {}", x.description.clone().unwrap_or_default(), x.tags.join(" ")),
|
||||
));
|
||||
(s > 0).then_some((x.name.as_str(), s))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
assert_eq!(scored[0].0, "python-mate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! sync: push the runtime state (state.json, journal, history) into the
|
||||
//! configured git repository (issue #66). Secrets are excluded by the
|
||||
//! managed .gitignore; conflicts resolve "last writer wins" with a backup.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn run(app: &App, message: Option<&str>) -> Result<i32> {
|
||||
let msg = message.unwrap_or("am sync — state update").to_string();
|
||||
match crate::sync::sync(app, &msg)? {
|
||||
crate::sync::SyncOutcome::Skipped => {
|
||||
app.log.info(
|
||||
"no settings.sync_repo configured — add 'sync_repo: <url>' to the config (issue #66)",
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
crate::sync::SyncOutcome::Clean => {
|
||||
app.log.info("nothing to sync — state already committed");
|
||||
Ok(0)
|
||||
}
|
||||
crate::sync::SyncOutcome::Pushed { files, message } => {
|
||||
app.log
|
||||
.success(&format!("state pushed ({files} file(s)): {message}"));
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! theme: switch the active color theme and persist it for the next start.
|
||||
//!
|
||||
//! With no argument, prints the current theme and the available list.
|
||||
//! With a name, switches to that theme (also used by the REPL 'theme' command).
|
||||
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
pub fn run(app: &App, name: Option<&str>) -> Result<i32> {
|
||||
if let Some(name) = name {
|
||||
let theme = theme::find(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown theme '{name}' — available: {}",
|
||||
theme::names().join(", ")
|
||||
)
|
||||
})?;
|
||||
app.set_theme(theme);
|
||||
crate::commands::config_cmd::persist_setting(app, "settings.theme", theme.name)?;
|
||||
app.log.success(&format!(
|
||||
"theme set to {} ({})",
|
||||
theme.name,
|
||||
theme.title
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// No argument: show current and list.
|
||||
let current = app.theme();
|
||||
if app.json() {
|
||||
crate::output::print_json(&serde_json::json!({
|
||||
"current": current.name,
|
||||
"title": current.title,
|
||||
"available": theme::names(),
|
||||
}));
|
||||
} else {
|
||||
println!("current theme: {} — {}", current.name, current.title);
|
||||
println!("available themes: {}", theme::names().join(", "));
|
||||
println!("use 'am theme <name>' to switch");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
//! tip: a cheat sheet of the most useful am commands with their key
|
||||
//! options — plus the user's own most-used commands (from the REPL
|
||||
//! history) and a rotating "did you know" tip.
|
||||
//!
|
||||
//! 'am tip' prints the whole page; 'am tip --random' prints a single tip;
|
||||
//! 'am tip --json' emits the same content as structured JSON.
|
||||
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use crate::history::HistoryEntry;
|
||||
use crate::theme::Theme;
|
||||
use serde_json::json;
|
||||
|
||||
/// One cheat-sheet entry: usage line, one-line purpose, key options and a
|
||||
/// concrete example.
|
||||
pub struct TipEntry {
|
||||
pub usage: &'static str,
|
||||
pub about: &'static str,
|
||||
pub options: &'static [(&'static str, &'static str)],
|
||||
pub example: &'static str,
|
||||
}
|
||||
|
||||
/// One section of the cheat sheet (icon + title + entries).
|
||||
pub struct TipSection {
|
||||
pub icon: &'static str,
|
||||
pub title: &'static str,
|
||||
pub entries: &'static [TipEntry],
|
||||
}
|
||||
|
||||
/// The curated cheat sheet, ordered from everyday actions to expert tools.
|
||||
pub static SECTIONS: &[TipSection] = &[
|
||||
TipSection {
|
||||
icon: "🚀",
|
||||
title: "Lancer & piloter",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "start <agent>",
|
||||
about: "démarre un agent (premier plan par défaut)",
|
||||
options: &[
|
||||
("-b, --background", "détaché en arrière-plan, sortie dans le log"),
|
||||
("--args \"...\"", "arguments supplémentaires passés à l'agent"),
|
||||
("--env KEY=VALUE", "variable d'environnement (répétable)"),
|
||||
("--profile <nom>", "profil d'environnement (env + args)"),
|
||||
],
|
||||
example: "start claude-code --background",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "stop <agent>",
|
||||
about: "arrête un agent en arrière-plan (SIGTERM puis SIGKILL)",
|
||||
options: &[
|
||||
("--force", "kill immédiat, sans délai de grâce"),
|
||||
("--timeout <secs>", "délai de grâce avant le kill (défaut : 5 s)"),
|
||||
],
|
||||
example: "stop pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "restart <agent>",
|
||||
about: "redémarre avec les mêmes options (agent, groupe, profil…)",
|
||||
options: &[],
|
||||
example: "restart group:dev",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "watch <agent>",
|
||||
about: "supervise l'agent et le relance s'il meurt",
|
||||
options: &[
|
||||
("--restart", "relance automatique avec backoff"),
|
||||
("--notify", "notification bureau à chaque relance"),
|
||||
],
|
||||
example: "watch smelt --restart",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "run <agent>",
|
||||
about: "exécute la commande de l'agent directement (sans gestion de processus)",
|
||||
options: &[("--args \"...\"", "arguments transmis tels quels à l'agent")],
|
||||
example: "run claude-code --version",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "📦",
|
||||
title: "Installer & maintenir",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "list",
|
||||
about: "agents installés (managed + external détectés sur le PATH)",
|
||||
options: &[
|
||||
("--all", "catalogue complet, y compris non installés"),
|
||||
("--running", "uniquement les agents actifs"),
|
||||
("--sort <key>", "name, version ou status"),
|
||||
("--category <cat>", "filtre par catégorie"),
|
||||
],
|
||||
example: "list --all",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "search <mot>",
|
||||
about: "cherche dans le catalogue (nom, description, catégorie, tags)",
|
||||
options: &[],
|
||||
example: "search pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "suggest <requête>",
|
||||
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
|
||||
options: &[("--json", "sortie machine-readable")],
|
||||
example: "suggest un agent pour du Python",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "ask <demande>",
|
||||
about: "langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (issue #78)",
|
||||
options: &[("--yes", "exécute la commande sans confirmation")],
|
||||
example: "ask installe claude et lance-le",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "ai <demande> [--exec] [--files <path>] [--role <rôle>]",
|
||||
about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut",
|
||||
options: &[
|
||||
("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"),
|
||||
("-f, --files", "fichiers/dossiers en contexte (défaut: dossier courant)"),
|
||||
("--role", "rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
|
||||
("--provider", "provider du registre (clé du keyring + modèle)"),
|
||||
("--model", "forcer un modèle"),
|
||||
("--yes", "exécute sans confirmation"),
|
||||
],
|
||||
example: "ai --exec \"compresse les fichiers JSON en un zip\"",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "setup [--roles]",
|
||||
about: "wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat, rôles copilot am-* (v1.1.0)",
|
||||
options: &[
|
||||
("--roles", "régénérer uniquement les rôles copilot aichat"),
|
||||
("--yes", "non-interactif (valeurs actuelles)"),
|
||||
],
|
||||
example: "setup",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "models",
|
||||
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
|
||||
options: &[
|
||||
("--prune", "purge les modèles inutilisés"),
|
||||
("--dry-run", "simulation sans rien supprimer"),
|
||||
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
|
||||
],
|
||||
example: "models --prune --dry-run",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "catalog update",
|
||||
about: "rafraîchit le catalogue officiel (diff + confirmation)",
|
||||
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
|
||||
example: "catalog add https://git.dracodev.net/team/agents.yaml",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "registry publish <catalogue.yaml> --source <url>",
|
||||
about: "registre communautaire : publie, cherche ou installe un catalogue (manifeste + checksum sha256 vérifié)",
|
||||
options: &[
|
||||
("search <mot>", "cherche dans le registre"),
|
||||
("install <url>", "installe après vérification du checksum et décision de confiance"),
|
||||
],
|
||||
example: "registry search agents python",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "audit",
|
||||
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
|
||||
options: &[("--json", "rapport machine-readable")],
|
||||
example: "audit --json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "service install <agent> --autostart",
|
||||
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
|
||||
options: &[
|
||||
("status <agent>", "état du service"),
|
||||
("uninstall <agent>", "retire le service"),
|
||||
],
|
||||
example: "service install claude-code --autostart",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "schedule add <cmd...> --at HH:MM",
|
||||
about: "planifie une commande am quotidienne (ex: update --all le matin)",
|
||||
options: &[
|
||||
("schedule list", "liste les planifications"),
|
||||
("schedule remove <id>", "supprime une planification"),
|
||||
],
|
||||
example: "schedule add update --all --at 06:30",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "doctor --watch <secondes>",
|
||||
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
|
||||
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
|
||||
example: "doctor --watch 60",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "start group:dev --parallel",
|
||||
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
|
||||
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
|
||||
example: "start group:dev --parallel",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update --rollback",
|
||||
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
|
||||
options: &[
|
||||
("--rollback list", "liste les points de restauration"),
|
||||
("--rollback <id>", "restaure un point précis"),
|
||||
],
|
||||
example: "update --rollback list",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "init --template <stack>",
|
||||
about: "génère une config projet pré-remplie (web, python, rust, cli)",
|
||||
options: &[("--template list", "liste les templates disponibles")],
|
||||
example: "init --template python",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "sessions --export <id>",
|
||||
about: "exporte une session : métadonnées + commandes + extrait de log (JSON)",
|
||||
options: &[
|
||||
("--output <fichier>", "destination (défaut ./am-session-<id>.json)"),
|
||||
("--retention <jours>", "purge les sessions terminées au-delà de N jours"),
|
||||
],
|
||||
example: "sessions --export 20260815_143926 --output session.json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "install <agent>",
|
||||
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
|
||||
options: &[
|
||||
("--method <m>", "choisit la méthode (index ou type:npm, binary…)"),
|
||||
("--force", "réinstalle même si déjà présent"),
|
||||
],
|
||||
example: "install pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update <agent>",
|
||||
about: "met à jour un agent vers la dernière version disponible",
|
||||
options: &[("--all", "tous les agents installés d'un coup")],
|
||||
example: "update --all",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "uninstall <agent>",
|
||||
about: "supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)",
|
||||
options: &[("--purge", "supprime aussi les logs et la définition de la config")],
|
||||
example: "uninstall pi",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "👁️",
|
||||
title: "Observer",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "status",
|
||||
about: "état de tous les agents (running / installed / external)",
|
||||
options: &[],
|
||||
example: "status",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "logs <agent>",
|
||||
about: "lit la fin du journal de l'agent",
|
||||
options: &[
|
||||
("--follow", "suit le log en direct"),
|
||||
("--lines <N>", "nombre de lignes (défaut : 20)"),
|
||||
],
|
||||
example: "logs pi --follow",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "dashboard",
|
||||
about: "TUI en direct : activité, stats, sessions, projets",
|
||||
options: &[],
|
||||
example: "dashboard",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "sessions · stats · top",
|
||||
about: "historique des sessions · statistiques · agents les plus utilisés",
|
||||
options: &[("--period <p>", "7d, 30d, 90d ou all"), ("stats --costs", "tokens + coût estimé par agent (issue #49)")],
|
||||
example: "stats --period 30d --costs",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "monitor",
|
||||
about: "TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils",
|
||||
options: &[
|
||||
("--interval <s>", "rafraîchissement (défaut 2s)"),
|
||||
("--json", "flux JSON pour les scripts"),
|
||||
],
|
||||
example: "monitor --interval 5",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "web",
|
||||
about: "dashboard web local en lecture seule : vue d'ensemble, graphiques, sessions, événements, projets (issue #54)",
|
||||
options: &[
|
||||
("--port <p>", "port d'écoute (défaut 7878)"),
|
||||
("--no-open", "ne pas ouvrir le navigateur automatiquement"),
|
||||
],
|
||||
example: "web --port 9090",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "serve --token <tok>",
|
||||
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (stats, run, start, stop, ask) (issue #80)",
|
||||
options: &[
|
||||
("--port <p>", "port d'écoute (défaut 8080)"),
|
||||
("--host <h>", "adresse d'écoute (défaut 127.0.0.1)"),
|
||||
("--rate-limit <n>", "requêtes par IP et par minute (défaut 120)"),
|
||||
],
|
||||
example: "serve --token mon-token --port 9000",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "sync",
|
||||
about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)",
|
||||
options: &[("--message <m>", "message de commit personnalisé"), ("sync_on_exit: true", "pousse automatiquement à la fermeture du REPL")],
|
||||
example: "sync --message \"sauvegarde du soir\"",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "migrate",
|
||||
about: "export/import d'un bundle de transfert machine A → B (config + état + historique + backups)",
|
||||
options: &[("--export", "crée agent-manager-migrate.amx"), ("--output <f>", "chemin du bundle")],
|
||||
example: "migrate --export --output backup.amx",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "history 12..25 --save deploy.yaml",
|
||||
about: "exporte une plage d'historique en playbook YAML (état_dir/playbooks/)",
|
||||
options: &[("--save <f>", "fichier playbook"), ("playbook <f>", "rejoue pas à pas avec confirmation")],
|
||||
example: "history 12..25 --save deploy.yaml && playbook deploy.yaml",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "lab --agents claude-code,aider --task hello.yaml",
|
||||
about: "benchmark : même tâche sur plusieurs agents (durée, exit, coût)",
|
||||
options: &[("--parallel", "exécution concurrente"), ("--json", "rapport comparatif stable (scripts/CI)")],
|
||||
example: "lab --agents claude-code,aider --task bench/hello.yaml --json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "plugins --test notify",
|
||||
about: "scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)",
|
||||
options: &[("--test <name>", "exécute un plugin sur un événement de test (CI)")],
|
||||
example: "plugins && plugins --test notify",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "timeline",
|
||||
about: "une vue chronologique de toute l'activité",
|
||||
options: &[("--since <date>", "activité à partir d'une date")],
|
||||
example: "timeline --since 2026-08-01",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "⚙️",
|
||||
title: "Personnaliser",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "alias add <nom> <agent>",
|
||||
about: "crée un raccourci (ex. cc → claude-code)",
|
||||
options: &[],
|
||||
example: "alias add cc claude-code",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "profile",
|
||||
about: "profils d'environnement réutilisables (env + args)",
|
||||
options: &[],
|
||||
example: "start pi --profile dev",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "config show",
|
||||
about: "configuration effective (catalogue fusionné, alias, groupes…)",
|
||||
options: &[],
|
||||
example: "config show",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "theme <nom>",
|
||||
about: "change la palette du shell, des tableaux et de cette page",
|
||||
options: &[],
|
||||
example: "theme ocean",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "secret set <nom> --agent <a> --value <v>",
|
||||
about: "stocke un secret dans le keyring de l'OS, jamais en clair",
|
||||
options: &[],
|
||||
example: "secret set API_KEY --agent pi --value sk-…",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
|
||||
about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring",
|
||||
options: &[],
|
||||
example: "providers set-token deepseek --value sk-…",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "favorite · note · tag",
|
||||
about: "annotez vos agents : étoile, note libre, tags personnels",
|
||||
options: &[],
|
||||
example: "tag pi perso",
|
||||
},
|
||||
],
|
||||
},
|
||||
TipSection {
|
||||
icon: "🧰",
|
||||
title: "Boîte à outils",
|
||||
entries: &[
|
||||
TipEntry {
|
||||
usage: "doctor --fix",
|
||||
about: "diagnostique l'environnement et répare les problèmes courants",
|
||||
options: &[],
|
||||
example: "doctor --fix",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "export · import",
|
||||
about: "sauvegarde config + état, puis restauration",
|
||||
options: &[("--output <file>", "fichier de destination (défaut : am-export.json)")],
|
||||
example: "export --output backup.json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "completion <shell> --installed",
|
||||
about: "complétion dynamique : agents installés, alias et groupes au Tab",
|
||||
options: &[],
|
||||
example: "completion powershell --installed",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "man <commande>",
|
||||
about: "page de manuel pour une commande",
|
||||
options: &[],
|
||||
example: "man start",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "help --find <mot>",
|
||||
about: "cherche une commande dans toute l'aide",
|
||||
options: &[],
|
||||
example: "help --find log",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "self-update",
|
||||
about: "met à jour agent-manager lui-même",
|
||||
options: &[("--check", "vérifie seulement si une version existe")],
|
||||
example: "self-update --check",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// Rotating pro tips ("Le saviez-vous ?").
|
||||
const DID_YOU_KNOW: &[(&str, &str)] = &[
|
||||
(
|
||||
"Tab complète vos agents",
|
||||
"dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.",
|
||||
),
|
||||
(
|
||||
"!! relance la dernière commande",
|
||||
"l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.",
|
||||
),
|
||||
(
|
||||
"Le shell am double le shell système",
|
||||
"tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.",
|
||||
),
|
||||
(
|
||||
"--dry-run simule sans rien changer",
|
||||
"testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.",
|
||||
),
|
||||
(
|
||||
"Les groupes lancent plusieurs agents",
|
||||
"'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.",
|
||||
),
|
||||
(
|
||||
"Cherchez dans toute l'aide",
|
||||
"'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.",
|
||||
),
|
||||
(
|
||||
"--json pour les scripts",
|
||||
"quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.",
|
||||
),
|
||||
(
|
||||
"Les alias raccourcissent tout",
|
||||
"'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.",
|
||||
),
|
||||
(
|
||||
"export/import sauvegardent votre monde",
|
||||
"config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.",
|
||||
),
|
||||
(
|
||||
"Les secrets vivent dans le keyring",
|
||||
"'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.",
|
||||
),
|
||||
(
|
||||
"Le thème suit votre humeur",
|
||||
"'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.",
|
||||
),
|
||||
(
|
||||
"doctor répare",
|
||||
"'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).",
|
||||
),
|
||||
];
|
||||
|
||||
/// Entry point: print the cheat sheet page (or a single random tip).
|
||||
pub fn run(app: &App, random: bool) -> Result<i32> {
|
||||
print!("{}", render(app, random));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Render the page as text (or JSON with --json).
|
||||
pub fn render(app: &App, random: bool) -> String {
|
||||
if app.json() {
|
||||
return render_json(app, random);
|
||||
}
|
||||
let p = Painter {
|
||||
theme: app.theme(),
|
||||
color: app.color(),
|
||||
};
|
||||
if random {
|
||||
let (title, body) = random_tip();
|
||||
return format!(
|
||||
"💡 {} — {}\n",
|
||||
p.acc(crate::i18n::tr_in(app.lang(), title)),
|
||||
crate::i18n::tr_in(app.lang(), body)
|
||||
);
|
||||
}
|
||||
let mut out = String::new();
|
||||
out.push_str(&header(app, &p));
|
||||
out.push('\n');
|
||||
for s in SECTIONS {
|
||||
out.push_str(§ion_rule(app, &p, s.icon, s.title));
|
||||
out.push('\n');
|
||||
for e in s.entries {
|
||||
out.push_str(&entry(&p, app.lang(), e));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&most_used_section(app, &p));
|
||||
out.push('\n');
|
||||
let (title, body) = daily_tip();
|
||||
out.push_str(§ion_rule(app, &p, "💡", "Le saviez-vous ?"));
|
||||
out.push('\n');
|
||||
out.push_str(&format!(
|
||||
" {} — {}\n",
|
||||
p.acc(crate::i18n::tr_in(app.lang(), title)),
|
||||
crate::i18n::tr_in(app.lang(), body)
|
||||
));
|
||||
out.push('\n');
|
||||
out.push_str(&format!(
|
||||
" {}\n",
|
||||
p.dim(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'"
|
||||
))
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Width of the cheat sheet, following the terminal (capped).
|
||||
fn page_width(app: &App) -> usize {
|
||||
crate::output::terminal_width()
|
||||
.unwrap_or(100)
|
||||
.clamp(50, 110)
|
||||
}
|
||||
|
||||
/// Tiny themed painter that honours the global --no-color flag.
|
||||
struct Painter<'a> {
|
||||
theme: &'a Theme,
|
||||
color: bool,
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn paint(&self, f: fn(&Theme, &str) -> String, s: &str) -> String {
|
||||
if self.color {
|
||||
f(self.theme, s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
fn acc(&self, s: &str) -> String {
|
||||
self.paint(Theme::acc, s)
|
||||
}
|
||||
fn inf(&self, s: &str) -> String {
|
||||
self.paint(Theme::inf, s)
|
||||
}
|
||||
fn dim(&self, s: &str) -> String {
|
||||
self.paint(Theme::dimmed, s)
|
||||
}
|
||||
fn hdr(&self, s: &str) -> String {
|
||||
self.paint(Theme::hdr, s)
|
||||
}
|
||||
fn frame(&self, s: &str) -> String {
|
||||
self.paint(Theme::frame, s)
|
||||
}
|
||||
fn val(&self, s: &str) -> String {
|
||||
self.paint(Theme::val, s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Title box: a framed header with a subtitle line.
|
||||
fn header(app: &App, p: &Painter) -> String {
|
||||
let w = page_width(app);
|
||||
let title = " 🧠 TIP · votre antisèche de commandes am ";
|
||||
let pad = w.saturating_sub(title.chars().count() + 2).max(1);
|
||||
let top = format!("╭─{title}{}╮", "─".repeat(pad));
|
||||
let sub = " les commandes les plus utiles · vos habitudes · une astuce du jour";
|
||||
let subpad = w.saturating_sub(sub.chars().count() + 2).max(1);
|
||||
let mid = format!("│{sub}{}│", " ".repeat(subpad));
|
||||
let bot = format!("╰{}╯", "─".repeat(w.saturating_sub(2).max(2)));
|
||||
format!(
|
||||
"{}\n{}\n{}\n",
|
||||
p.frame(&top),
|
||||
p.dim(&mid),
|
||||
p.frame(&bot)
|
||||
)
|
||||
}
|
||||
|
||||
/// Section separator: " 🚀 Lancer & piloter ────────".
|
||||
fn section_rule(app: &App, p: &Painter, icon: &str, title: &str) -> String {
|
||||
let w = page_width(app);
|
||||
let label = format!(" {icon} {} ", crate::i18n::tr_in(app.lang(), title));
|
||||
let dashes = "─".repeat(w.saturating_sub(label.chars().count()).max(1));
|
||||
format!("{}{}", p.hdr(&label), p.frame(&dashes))
|
||||
}
|
||||
|
||||
/// One cheat-sheet entry: usage line, options, example.
|
||||
fn entry(p: &Painter, lang: crate::i18n::Lang, e: &TipEntry) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
" {} {} {}\n",
|
||||
p.inf("▸"),
|
||||
p.acc(e.usage),
|
||||
crate::i18n::tr_in(lang, e.about)
|
||||
));
|
||||
for (flag, desc) in e.options {
|
||||
out.push_str(&format!(
|
||||
" {} {}\n",
|
||||
p.dim(flag),
|
||||
crate::i18n::tr_in(lang, desc)
|
||||
));
|
||||
}
|
||||
if !e.example.is_empty() {
|
||||
out.push_str(&format!(" {} {}\n", p.dim("❯"), p.val(e.example)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Count the am commands of a history (first token per entry), most used
|
||||
/// first, truncated to 8. Pure and testable.
|
||||
pub fn count_commands(entries: &[HistoryEntry]) -> Vec<(String, usize)> {
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for e in entries {
|
||||
if e.kind != "am" {
|
||||
continue;
|
||||
}
|
||||
if let Some(first) = e.cmd.split_whitespace().next() {
|
||||
if !first.is_empty() {
|
||||
*counts.entry(first.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
v.truncate(8);
|
||||
v
|
||||
}
|
||||
|
||||
/// The user's most-used commands, from the REPL history.
|
||||
fn most_used(app: &App) -> Vec<(String, usize)> {
|
||||
count_commands(&crate::history::read_all(app, 0))
|
||||
}
|
||||
|
||||
/// A 10-cell usage bar: ▰ for used cells, ▱ for the rest.
|
||||
fn usage_bar(count: usize, max: usize) -> String {
|
||||
if max == 0 {
|
||||
return "▱".repeat(10);
|
||||
}
|
||||
let filled = (count * 10 + max / 2) / max;
|
||||
format!(
|
||||
"{}{}",
|
||||
"▰".repeat(filled.min(10)),
|
||||
"▱".repeat(10usize.saturating_sub(filled.min(10)))
|
||||
)
|
||||
}
|
||||
|
||||
/// "🔥 Vos commandes les plus utilisées": a little bar chart from the
|
||||
/// REPL history, so the page grows with the user.
|
||||
fn most_used_section(app: &App, p: &Painter) -> String {
|
||||
let top = most_used(app);
|
||||
let mut out = String::new();
|
||||
out.push_str(§ion_rule(app, p, "🔥", "Vos commandes les plus utilisées"));
|
||||
out.push('\n');
|
||||
if top.is_empty() {
|
||||
out.push_str(&format!(
|
||||
" {} — {}\n",
|
||||
p.dim(crate::i18n::tr_in(app.lang(), "aucune commande enregistrée")),
|
||||
crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici"
|
||||
)
|
||||
));
|
||||
return out;
|
||||
}
|
||||
let max = top[0].1;
|
||||
let name_w = top.iter().map(|(n, _)| n.chars().count()).max().unwrap_or(0).min(14);
|
||||
for (name, count) in &top {
|
||||
out.push_str(&format!(
|
||||
" {} {} {}×\n",
|
||||
p.acc(&format!("{name:<name_w$}")),
|
||||
p.dim(&usage_bar(*count, max)),
|
||||
p.inf(&count.to_string()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Tip of the day: deterministic pick that changes daily.
|
||||
fn daily_tip() -> (&'static str, &'static str) {
|
||||
let day = chrono::Local::now().ordinal() as usize;
|
||||
DID_YOU_KNOW[day % DID_YOU_KNOW.len()]
|
||||
}
|
||||
|
||||
/// Random tip for '--random'.
|
||||
fn random_tip() -> (&'static str, &'static str) {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as usize)
|
||||
.unwrap_or(0);
|
||||
DID_YOU_KNOW[nanos % DID_YOU_KNOW.len()]
|
||||
}
|
||||
|
||||
/// The same content as structured JSON (--json).
|
||||
fn render_json(app: &App, random: bool) -> String {
|
||||
let (title, body) = if random { random_tip() } else { daily_tip() };
|
||||
let sections: Vec<serde_json::Value> = SECTIONS
|
||||
.iter()
|
||||
.map(|s| {
|
||||
json!({
|
||||
"icon": s.icon,
|
||||
"title": s.title,
|
||||
"entries": s.entries.iter().map(|e| json!({
|
||||
"usage": e.usage,
|
||||
"about": e.about,
|
||||
"options": e.options.iter().map(|(flag, desc)| json!({ "flag": flag, "desc": desc })).collect::<Vec<_>>(),
|
||||
"example": e.example,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({
|
||||
"sections": sections,
|
||||
"most_used": most_used(app).iter().map(|(command, count)| json!({ "command": command, "count": count })).collect::<Vec<_>>(),
|
||||
"tip": { "title": title, "body": body },
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(cmd: &str, kind: &str) -> HistoryEntry {
|
||||
HistoryEntry::now(cmd.to_string()).with_kind(kind.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_only_am_commands_by_first_token() {
|
||||
let entries = vec![
|
||||
entry("start pi", "am"),
|
||||
entry("start pi", "am"),
|
||||
entry("list", "am"),
|
||||
entry("git status", "shell"),
|
||||
entry("start claude-code", "am"),
|
||||
entry("", "am"),
|
||||
];
|
||||
let top = count_commands(&entries);
|
||||
assert_eq!(top[0], ("start".to_string(), 3));
|
||||
assert_eq!(top[1], ("list".to_string(), 1));
|
||||
assert_eq!(top.len(), 2, "shell commands and empty lines are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_yields_no_commands() {
|
||||
assert!(count_commands(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_bars_fill_and_cap_at_ten_cells() {
|
||||
assert_eq!(usage_bar(10, 10), "▰".repeat(10));
|
||||
assert_eq!(usage_bar(0, 10), "▱".repeat(10));
|
||||
assert_eq!(usage_bar(5, 10), "▰".repeat(5) + &"▱".repeat(5));
|
||||
// A count larger than the max still fits the bar.
|
||||
assert_eq!(usage_bar(99, 10).chars().count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_tip_is_always_in_bounds() {
|
||||
let (t, b) = daily_tip();
|
||||
assert!(DID_YOU_KNOW.iter().any(|(x, y)| *x == t && *y == b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_are_non_empty_and_consistent() {
|
||||
for s in SECTIONS {
|
||||
assert!(!s.entries.is_empty(), "section {} has no entries", s.title);
|
||||
for e in s.entries {
|
||||
assert!(!e.usage.is_empty());
|
||||
assert!(!e.about.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-1
@@ -2,6 +2,7 @@
|
||||
//! reinstall when an update exists.
|
||||
|
||||
use super::*;
|
||||
use crate::backup;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::{Runner, SystemRunner};
|
||||
@@ -9,15 +10,45 @@ use anyhow::{bail, Result};
|
||||
use std::cmp::Ordering;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent_name: Option<&str>,
|
||||
all: bool,
|
||||
rollback: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
// Issue #64: --rollback restores a pre-update backup instead of updating.
|
||||
if rollback.is_some() {
|
||||
return rollback_point(app, rollback);
|
||||
}
|
||||
|
||||
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
|
||||
|
||||
let policy = app.config.settings.update_policy.unwrap_or_default();
|
||||
if policy == UpdatePolicy::None && all {
|
||||
app.log.warn("update policy is 'none'; 'am update --all' is a no-op");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if let Some(name) = agent_name {
|
||||
if policy == UpdatePolicy::None && !app.yes() {
|
||||
app.log.warn("update policy is 'none'; use --yes to force a single-agent update");
|
||||
}
|
||||
return update_one(app, &runner, name);
|
||||
}
|
||||
if all {
|
||||
let entries = app.state.all()?;
|
||||
let names: Vec<String> = entries.keys().cloned().collect();
|
||||
let mut failed = 0;
|
||||
// Issue #64: one snapshot covers the whole batch.
|
||||
if !app.dry_run() && !names.is_empty() {
|
||||
let info = backup::create_backup(app, "update:--all", &names)?;
|
||||
app.log.verbose(&format!("pre-update backup: {}", info.id));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Backup)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_reason("update:--all"),
|
||||
);
|
||||
}
|
||||
for name in names {
|
||||
if let Err(e) = update_one(app, &runner, &name) {
|
||||
failed += 1;
|
||||
@@ -31,6 +62,30 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
|
||||
|
||||
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
let agent = require_agent(app, name)?;
|
||||
|
||||
// Issue #65: honor per-agent pinned version.
|
||||
if let Some(pin) = agent.pin_version.as_deref() {
|
||||
let current = app
|
||||
.state
|
||||
.get(&agent.name)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|e| e.version.clone());
|
||||
if current.as_deref() == Some(pin) {
|
||||
app.log.success(&format!(
|
||||
"{} is pinned to v{pin} and already at that version",
|
||||
agent.title()
|
||||
));
|
||||
} else {
|
||||
app.log.warn(&format!(
|
||||
"{} is pinned to v{pin} but currently at {}; update skipped",
|
||||
agent.title(),
|
||||
current.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
|
||||
bail!(
|
||||
"agent '{}' is not installed by agent-manager — use 'am install {}'",
|
||||
@@ -90,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
|
||||
return Ok(0);
|
||||
}
|
||||
// Issue #64: snapshot before the real update so a broken upgrade can be
|
||||
// undone with 'am update --rollback'.
|
||||
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
|
||||
app.log.verbose(&format!("pre-update backup: {}", info.id));
|
||||
app.emit(
|
||||
&Event::now(EventKind::Backup)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason("pre-update"),
|
||||
);
|
||||
crate::deps::ensure_deps(app, agent, runner)?;
|
||||
let outcome = installers::run_install(app, agent, method, runner)?;
|
||||
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
|
||||
@@ -113,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
|
||||
/// '--rollback list' prints the available backup points.
|
||||
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
|
||||
if point == Some("list") {
|
||||
let all = backup::list_backups(app);
|
||||
if all.is_empty() {
|
||||
app.log.info("no backup found — run an update first to create one");
|
||||
return Ok(0);
|
||||
}
|
||||
println!("pre-update backups ({}):", all.len());
|
||||
for b in &all {
|
||||
println!(
|
||||
" {} {} agents={}{}",
|
||||
b.id,
|
||||
b.reason,
|
||||
if b.agents.is_empty() {
|
||||
"all".to_string()
|
||||
} else {
|
||||
b.agents.join(",")
|
||||
},
|
||||
if b.state && b.config { " [state+config]" } else { "" }
|
||||
);
|
||||
}
|
||||
println!("\nuse 'am update --rollback <id>' to restore one");
|
||||
return Ok(0);
|
||||
}
|
||||
let info = backup::restore_backup(app, point)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Rollback)
|
||||
.with_args(vec![info.id.clone()])
|
||||
.with_reason(&info.reason),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"rolled back to backup {} ({})",
|
||||
info.id, info.reason
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! web: serve the local read-only dashboard (issue #54).
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn run(app: &App, port: Option<u16>, no_open: bool) -> Result<i32> {
|
||||
crate::web::run(app, port.unwrap_or(crate::web::DEFAULT_PORT), !no_open)
|
||||
}
|
||||
+548
-8
@@ -102,6 +102,252 @@ pub struct Settings {
|
||||
/// lines (global; project profiles can add their own).
|
||||
#[serde(default)]
|
||||
pub hooks: BTreeMap<String, Vec<String>>,
|
||||
/// Update policy: how aggressively 'am update' should act (issue #65).
|
||||
#[serde(default)]
|
||||
pub update_policy: Option<UpdatePolicy>,
|
||||
/// A model is a prune candidate after this many days without use
|
||||
/// (issue #73, default 30).
|
||||
#[serde(default)]
|
||||
pub models_prune_days: Option<u64>,
|
||||
/// Base URL of the official catalog used by 'am catalog update'
|
||||
/// (issue #60; default: the embedded catalog on Gitea).
|
||||
#[serde(default)]
|
||||
pub catalog_url: Option<String>,
|
||||
/// How long (seconds) a remote catalog stays cached before a refresh
|
||||
/// is attempted (issue #60, default 3600).
|
||||
#[serde(default)]
|
||||
pub catalog_cache_ttl_secs: Option<u64>,
|
||||
/// How many pre-update backups 'am update --rollback' keeps
|
||||
/// (issue #64, default 5).
|
||||
#[serde(default)]
|
||||
pub backups_keep: Option<u32>,
|
||||
/// Purge sessions finished more than N days ago (issue #53, default 90).
|
||||
#[serde(default)]
|
||||
pub sessions_retention_days: Option<u64>,
|
||||
/// Token price models used to estimate session costs (issue #49).
|
||||
#[serde(default)]
|
||||
pub cost_models: Option<BTreeMap<String, CostModel>>,
|
||||
/// CPU/memory thresholds for 'am monitor' alerts (issue #50).
|
||||
#[serde(default)]
|
||||
pub monitor_thresholds: Option<MonitorThresholds>,
|
||||
/// Git repository where 'am sync' pushes the state (issue #66).
|
||||
#[serde(default)]
|
||||
pub sync_repo: Option<String>,
|
||||
/// Push automatically when the REPL exits (issue #66, opt-in).
|
||||
#[serde(default)]
|
||||
pub sync_on_exit: Option<bool>,
|
||||
/// Anonymous opt-in telemetry (issue #76): aggregated counters only.
|
||||
/// Nothing is collected or sent while `enabled` is false (the default).
|
||||
#[serde(default)]
|
||||
pub telemetry: Option<TelemetrySettings>,
|
||||
/// am ask (issue #78): natural language → am command. The local rules
|
||||
/// are always available; the optional LLM refinement uses the provider
|
||||
/// registry. `enabled: false` turns the whole command off.
|
||||
#[serde(default)]
|
||||
pub ask: Option<AskSettings>,
|
||||
/// Community registry (issue #77): trusted catalog sources + the author
|
||||
/// name recorded by `am registry publish`.
|
||||
#[serde(default)]
|
||||
pub registry: Option<RegistrySettings>,
|
||||
/// Plugin scripts (issue #75): default timeout and enable list.
|
||||
#[serde(default)]
|
||||
pub plugins: Option<PluginSettings>,
|
||||
/// Name of the provider used by default (issue #88) — the provider
|
||||
/// whose default model is applied at install/run when none is given.
|
||||
#[serde(default)]
|
||||
pub default_provider: Option<String>,
|
||||
/// LLM provider registry (issue #88): provider name -> base URL,
|
||||
/// available models and default model. API tokens are NOT stored here —
|
||||
/// they live in the OS keyring under `providers/<name>/api_key`
|
||||
/// (issue #89).
|
||||
#[serde(default)]
|
||||
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
|
||||
/// am ai security settings (issue #97): default safety policy and
|
||||
/// extra risky command patterns for the Shell AI command.
|
||||
#[serde(default)]
|
||||
pub shell_ai: Option<ShellAiSettings>,
|
||||
}
|
||||
|
||||
/// Anonymous opt-in telemetry (issue #76): aggregated counters only — never
|
||||
/// paths, commands, agent names or identifiers. Disabled by default.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct TelemetrySettings {
|
||||
/// Explicit opt-in: nothing is collected or sent while false.
|
||||
pub enabled: bool,
|
||||
/// Batch endpoint (e.g. https://am-telemetry.example/v1/ping). When
|
||||
/// unset, counters stay local even when enabled.
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// am ask settings (issue #78): the LLM refinement is optional — without a
|
||||
/// provider, the local rule-based translator still works, offline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AskSettings {
|
||||
/// Master switch; false disables `am ask` entirely (issue #78).
|
||||
pub enabled: bool,
|
||||
/// Provider of the registry used for the refinement call. Falls back
|
||||
/// to settings.default_provider when unset. None = rules only.
|
||||
pub provider: Option<String>,
|
||||
/// Model for the refinement call. Falls back to the provider default.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AskSettings {
|
||||
fn default() -> Self {
|
||||
AskSettings {
|
||||
enabled: true,
|
||||
provider: None,
|
||||
model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// am ai security settings (issue #97): safety policy and risky patterns
|
||||
/// for the Shell AI command. The default policy is `dry-run` — nothing is
|
||||
/// executed without an explicit confirmation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ShellAiSettings {
|
||||
/// Default safety policy of `am ai --exec`:
|
||||
/// `dry-run` (show only), `confirm` (ask for risky commands) or
|
||||
/// `auto` (execute everything). Default: dry-run.
|
||||
pub default_safety: Option<String>,
|
||||
/// Extra command substrings classified as risky (in addition to the
|
||||
/// built-in patterns: rm -rf, dd if, mkfs, chmod -R 777, ...).
|
||||
#[serde(default)]
|
||||
pub risky_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShellAiSettings {
|
||||
/// The effective default safety mode (unknown values fall back to
|
||||
/// dry-run — the safe choice).
|
||||
pub fn safety_mode(&self) -> crate::shell_ai::SafetyMode {
|
||||
match self.default_safety.as_deref() {
|
||||
Some("auto") => crate::shell_ai::SafetyMode::Auto,
|
||||
Some("confirm") => crate::shell_ai::SafetyMode::Confirm,
|
||||
_ => crate::shell_ai::SafetyMode::DryRun,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Community registry settings (issue #77).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RegistrySettings {
|
||||
/// Trusted catalog sources: their manifests are validated (checksum)
|
||||
/// and their agents searchable through `am registry search`.
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
/// Author name recorded by `am registry publish` (default: "unknown").
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
/// Sandbox profile of an agent (issue #79): restrict which commands it may
|
||||
/// run, which working directories it may use, and whether it may reach the
|
||||
/// network. Enforcement is best-effort per platform — the launcher checks
|
||||
/// the resolved command and the process cwd, and a warning is emitted when
|
||||
/// the enforcement cannot be fully guaranteed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct SandboxProfile {
|
||||
/// Sandbox active for this agent (default false).
|
||||
pub enabled: bool,
|
||||
/// Allowed command names (basenames). Empty = no command restriction.
|
||||
#[serde(default)]
|
||||
pub commands: Vec<String>,
|
||||
/// Allowed working directories (support ~ and env vars). Empty = no
|
||||
/// directory restriction.
|
||||
#[serde(default)]
|
||||
pub dirs: Vec<String>,
|
||||
/// Network access (default true). false = best-effort block.
|
||||
#[serde(default = "default_true")]
|
||||
pub network: bool,
|
||||
}
|
||||
|
||||
/// One entry of the LLM provider registry (issue #88).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProviderDef {
|
||||
/// Base URL of the provider API (e.g. https://api.openai.com/v1).
|
||||
pub base_url: String,
|
||||
/// Model used by default for this provider.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
/// Models offered by this provider.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Plugin scripts settings (issue #75): how long one plugin may run and
|
||||
/// which plugins of the directory are enabled (empty = every plugin).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginSettings {
|
||||
/// Default timeout in seconds for every plugin (default 10).
|
||||
#[serde(default)]
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Only run these plugin names (empty = all the plugins of the dir).
|
||||
#[serde(default)]
|
||||
pub enabled: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Token price model: USD per million tokens (issue #49).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CostModel {
|
||||
pub input_per_mtok: f64,
|
||||
pub output_per_mtok: f64,
|
||||
}
|
||||
|
||||
/// Default price sheet (USD / MTok) used when settings.cost_models is absent.
|
||||
pub fn default_cost_models() -> BTreeMap<String, CostModel> {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("claude-sonnet-4".into(), CostModel { input_per_mtok: 3.0, output_per_mtok: 15.0 });
|
||||
m.insert("claude-opus-4".into(), CostModel { input_per_mtok: 15.0, output_per_mtok: 75.0 });
|
||||
m.insert("claude-haiku-3.5".into(), CostModel { input_per_mtok: 0.8, output_per_mtok: 4.0 });
|
||||
m.insert("gpt-4o".into(), CostModel { input_per_mtok: 2.5, output_per_mtok: 10.0 });
|
||||
m.insert("gpt-4o-mini".into(), CostModel { input_per_mtok: 0.15, output_per_mtok: 0.6 });
|
||||
m.insert("deepseek-chat".into(), CostModel { input_per_mtok: 0.27, output_per_mtok: 1.10 });
|
||||
m.insert("llama-3.1-8b".into(), CostModel { input_per_mtok: 0.05, output_per_mtok: 0.05 });
|
||||
m
|
||||
}
|
||||
|
||||
/// Alert thresholds for 'am monitor' (issue #50).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MonitorThresholds {
|
||||
/// Alert when a managed process exceeds this CPU % (0 = disabled).
|
||||
#[serde(default)]
|
||||
pub cpu_pct: Option<f64>,
|
||||
/// Alert when a managed process exceeds this RSS in MB (0 = disabled).
|
||||
#[serde(default)]
|
||||
pub mem_mb: Option<u64>,
|
||||
}
|
||||
|
||||
/// Policy controlling how aggressively 'am update' upgrades installed agents.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdatePolicy {
|
||||
/// Never update automatically; 'am update --all' is a no-op.
|
||||
None,
|
||||
/// Default: update only when explicitly requested by name or with --all.
|
||||
#[default]
|
||||
Manual,
|
||||
/// Update only to the next patch/minor that is known safe (future use).
|
||||
Safe,
|
||||
/// Always update to the latest available version.
|
||||
Latest,
|
||||
}
|
||||
|
||||
impl UpdatePolicy {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
UpdatePolicy::None => "none",
|
||||
UpdatePolicy::Manual => "manual",
|
||||
UpdatePolicy::Safe => "safe",
|
||||
UpdatePolicy::Latest => "latest",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A project profile: everything am needs to know about one project.
|
||||
@@ -141,6 +387,27 @@ impl Settings {
|
||||
self.stop_timeout_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up one provider of the registry (issue #88).
|
||||
pub fn provider(&self, name: &str) -> Option<&ProviderDef> {
|
||||
self.providers
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(name))
|
||||
.and_then(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Name of the default provider (issue #88).
|
||||
pub fn default_provider_name(&self) -> Option<&str> {
|
||||
self.default_provider.as_deref()
|
||||
}
|
||||
|
||||
/// The default provider entry, when both the name and the definition
|
||||
/// exist (issue #88).
|
||||
pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> {
|
||||
let name = self.default_provider.as_ref()?;
|
||||
let def = self.provider(name)?;
|
||||
Some((name, def))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -163,6 +430,13 @@ pub struct AgentDef {
|
||||
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
|
||||
#[serde(default)]
|
||||
pub run: Option<String>,
|
||||
/// Binary name probed on the PATH for external detection. When unset,
|
||||
/// the first token of `run` is used — which is wrong for interpreter
|
||||
/// launchers ("npx pkg", "python -m pkg"): the interpreter itself would
|
||||
/// be matched, reporting the agent as present whenever node/python is
|
||||
/// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai").
|
||||
#[serde(default)]
|
||||
pub detect: Option<String>,
|
||||
/// Default arguments appended to the run command.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
@@ -172,6 +446,32 @@ pub struct AgentDef {
|
||||
/// Known version (otherwise detected via --version).
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// Pinned version: 'am update' will refuse to install a different one (issue #65).
|
||||
#[serde(default)]
|
||||
pub pin_version: Option<String>,
|
||||
/// Environment variable carrying the model name at launch
|
||||
/// (issue #71, e.g. ANTHROPIC_MODEL for claude-code).
|
||||
#[serde(default)]
|
||||
pub model_env: Option<String>,
|
||||
/// CLI flag carrying the model name at launch (issue #71, e.g. "--model").
|
||||
#[serde(default)]
|
||||
pub model_arg: Option<String>,
|
||||
/// Preferred provider of this agent (issue #90, e.g. "anthropic" for
|
||||
/// claude-code). Falls back to settings.default_provider when unset.
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
/// Preferred model for this agent (issue #90). Falls back to the
|
||||
/// provider's default_model when unset.
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// Post-install provider configuration (issue #91).
|
||||
#[serde(default)]
|
||||
pub config: Option<AgentConfig>,
|
||||
/// Sandbox profile (issue #79): allowed commands, working directories
|
||||
/// and network policy. Disabled by default — the unsandboxed mode
|
||||
/// stays available and documented.
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<SandboxProfile>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// When false, the agent is listed but cannot be installed locally.
|
||||
@@ -186,6 +486,50 @@ pub struct AgentDef {
|
||||
/// Restrict the agent to platforms: linux, macos, windows (empty = all).
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
/// Health check waited before the next orchestration step (issue #57).
|
||||
#[serde(default)]
|
||||
pub healthcheck: Option<HealthCheck>,
|
||||
/// Container profile used by 'am run --container' (issue #58).
|
||||
#[serde(default)]
|
||||
pub container: Option<ContainerSpec>,
|
||||
/// Price model key (settings.cost_models) used to estimate session costs
|
||||
/// (issue #49; defaults to the first model of the price sheet).
|
||||
#[serde(default)]
|
||||
pub cost_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Health check for group orchestration (issue #57): a command that must
|
||||
/// exit 0, or a TCP port that must accept connections, within timeout_secs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HealthCheck {
|
||||
/// Shell command to probe (exit 0 = healthy).
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
/// TCP port to connect to (accepts = healthy).
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
/// Seconds to wait before declaring failure (default 30).
|
||||
#[serde(default)]
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Container profile (issue #58): image, volumes, network and env applied
|
||||
/// by 'am run <agent> --container'.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ContainerSpec {
|
||||
/// Container image (required).
|
||||
pub image: String,
|
||||
/// Extra volumes "host:container[:ro]" on top of state_dir/log_dir.
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<String>,
|
||||
/// Network (default: host on Linux, bridge elsewhere).
|
||||
#[serde(default)]
|
||||
pub network: Option<String>,
|
||||
/// Extra environment variables inside the container.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -211,6 +555,12 @@ impl AgentDef {
|
||||
})
|
||||
}
|
||||
|
||||
/// Binary probed on the PATH for external detection: the explicit
|
||||
/// `detect` name when set, otherwise the first token of `run`.
|
||||
pub fn detect_token(&self) -> Option<String> {
|
||||
self.detect.clone().or_else(|| self.first_token())
|
||||
}
|
||||
|
||||
/// The run command split into tokens (empty when unset).
|
||||
pub fn run_tokens(&self) -> Vec<String> {
|
||||
self.run
|
||||
@@ -385,6 +735,10 @@ pub struct InstallSpec {
|
||||
/// Commands executed after a successful install (PATH includes the bin dir).
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
/// The agent accepts a post-install provider configuration (issue #91).
|
||||
/// False for agents without any API (issue #90).
|
||||
#[serde(default = "default_true")]
|
||||
pub configurable: bool,
|
||||
}
|
||||
|
||||
impl InstallSpec {
|
||||
@@ -398,6 +752,33 @@ impl InstallSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-install provider configuration of an agent (issue #91): the env vars
|
||||
/// the agent expects and the config files to write or softly edit. The
|
||||
/// api_key slot is always injected as the `@secret` reference (issue #89) —
|
||||
/// a token is never written in clear.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AgentConfig {
|
||||
/// Expected env vars, keyed by semantic slot (api_key / model /
|
||||
/// base_url / provider) -> the agent's variable name.
|
||||
pub env_map: BTreeMap<String, String>,
|
||||
/// Configuration files to write or softly edit after the install.
|
||||
pub files: Vec<AgentConfigFile>,
|
||||
/// Provider of this agent, otherwise settings.default_provider.
|
||||
pub provider_default: Option<String>,
|
||||
}
|
||||
|
||||
/// One config file of an agent (issue #91).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentConfigFile {
|
||||
/// File path (supports ~ and env vars).
|
||||
pub path: String,
|
||||
/// Keys to write among: api_key, model, base_url, provider.
|
||||
#[serde(default)]
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
@@ -481,7 +862,10 @@ pub fn parse_file(path: &Path) -> Result<Config> {
|
||||
fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
if !visited.insert(canon.clone()) {
|
||||
return Err(anyhow!("config include cycle detected at {}", path.display()));
|
||||
return Err(anyhow!(
|
||||
"config include cycle detected at {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mut cfg = parse_file(path)?;
|
||||
let includes = cfg.include.clone();
|
||||
@@ -491,19 +875,55 @@ fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Resul
|
||||
let mut merged = Config::default();
|
||||
merged.version = cfg.version.clone();
|
||||
for inc in &includes {
|
||||
let inc_path = expand_path(inc);
|
||||
let abs = if inc_path.is_absolute() {
|
||||
inc_path
|
||||
} else {
|
||||
base_dir.join(inc_path)
|
||||
};
|
||||
let sub = load_file_with_includes(&abs, visited)?;
|
||||
let sub = load_include(inc, base_dir, visited)?;
|
||||
merge(&mut merged, sub);
|
||||
}
|
||||
merge(&mut merged, cfg);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Load one include entry: a local path (relative to the including file) or
|
||||
/// a remote catalog URL (http/https/file, cached on disk, issue #67).
|
||||
fn load_include(inc: &str, base_dir: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
|
||||
let lower = inc.to_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
let state_dir = crate::app::state_dir();
|
||||
let text = crate::catalog_remote::fetch_for_config(&state_dir, inc)?;
|
||||
let sub = crate::catalog_remote::parse_catalog(&text)
|
||||
.with_context(|| format!("remote include {inc} is not a valid catalog"))?;
|
||||
// Remote includes can themselves reference other URLs; local paths
|
||||
// inside them are resolved against the config directory.
|
||||
let mut merged = Config::default();
|
||||
merged.version = sub.version.clone();
|
||||
for sub_inc in &sub.include {
|
||||
let nested = load_include(sub_inc, base_dir, visited)?;
|
||||
merge(&mut merged, nested);
|
||||
}
|
||||
let mut own = sub;
|
||||
own.include.clear();
|
||||
merge(&mut merged, own);
|
||||
return Ok(merged);
|
||||
}
|
||||
if lower.starts_with("file://") {
|
||||
let local = inc.strip_prefix("file://").unwrap_or(inc);
|
||||
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
|
||||
let local = if cfg!(windows) && local.starts_with('/') {
|
||||
local.strip_prefix('/').unwrap_or(local)
|
||||
} else {
|
||||
local
|
||||
};
|
||||
let abs = PathBuf::from(local);
|
||||
return load_file_with_includes(&abs, visited);
|
||||
}
|
||||
let inc_path = expand_path(inc);
|
||||
let abs = if inc_path.is_absolute() {
|
||||
inc_path
|
||||
} else {
|
||||
base_dir.join(inc_path)
|
||||
};
|
||||
load_file_with_includes(&abs, visited)
|
||||
}
|
||||
|
||||
/// Merge an overlay config into a base config: user definitions replace
|
||||
/// default agents with the same name; maps are merged key by key.
|
||||
pub fn merge(base: &mut Config, overlay: Config) {
|
||||
@@ -536,6 +956,75 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.theme.is_some() {
|
||||
s.theme = o.theme;
|
||||
}
|
||||
if o.update_policy.is_some() {
|
||||
s.update_policy = o.update_policy;
|
||||
}
|
||||
if o.models_prune_days.is_some() {
|
||||
s.models_prune_days = o.models_prune_days;
|
||||
}
|
||||
if o.catalog_url.is_some() {
|
||||
s.catalog_url = o.catalog_url;
|
||||
}
|
||||
if o.catalog_cache_ttl_secs.is_some() {
|
||||
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs;
|
||||
}
|
||||
if o.backups_keep.is_some() {
|
||||
s.backups_keep = o.backups_keep;
|
||||
}
|
||||
if o.sessions_retention_days.is_some() {
|
||||
s.sessions_retention_days = o.sessions_retention_days;
|
||||
}
|
||||
if o.cost_models.is_some() {
|
||||
s.cost_models = o.cost_models;
|
||||
}
|
||||
if o.monitor_thresholds.is_some() {
|
||||
s.monitor_thresholds = o.monitor_thresholds;
|
||||
}
|
||||
if o.sync_repo.is_some() {
|
||||
s.sync_repo = o.sync_repo;
|
||||
}
|
||||
if o.sync_on_exit.is_some() {
|
||||
s.sync_on_exit = o.sync_on_exit;
|
||||
}
|
||||
if o.telemetry.is_some() {
|
||||
s.telemetry = o.telemetry;
|
||||
}
|
||||
if o.ask.is_some() {
|
||||
s.ask = o.ask;
|
||||
}
|
||||
if o.registry.is_some() {
|
||||
s.registry = o.registry;
|
||||
}
|
||||
if o.plugins.is_some() {
|
||||
s.plugins = o.plugins;
|
||||
}
|
||||
if o.default_provider.is_some() {
|
||||
s.default_provider = o.default_provider;
|
||||
}
|
||||
if o.shell_ai.is_some() {
|
||||
s.shell_ai = o.shell_ai;
|
||||
}
|
||||
// Providers merge key by key (issue #88): an overlay adds or replaces
|
||||
// one provider without wiping the others. An explicit `null` in the
|
||||
// overlay DELETES the provider (the standard YAML overlay pattern) so a
|
||||
// provider shipped in the default catalog can be removed by the user.
|
||||
if let Some(overlay_providers) = o.providers {
|
||||
match s.providers.as_mut() {
|
||||
Some(map) => {
|
||||
for (k, v) in overlay_providers {
|
||||
match v {
|
||||
Some(def) => {
|
||||
map.insert(k, Some(def));
|
||||
}
|
||||
None => {
|
||||
map.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => s.providers = Some(overlay_providers),
|
||||
}
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
@@ -601,6 +1090,32 @@ pub fn validate(config: &Config) -> Vec<String> {
|
||||
problems.push(format!("agent '{}': dependency without a name", agent.name));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &config.settings.default_provider {
|
||||
if config.settings.provider(d).is_none() {
|
||||
problems.push(format!(
|
||||
"settings.default_provider '{d}' is not defined in settings.providers"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(providers) = &config.settings.providers {
|
||||
for (name, p) in providers {
|
||||
// A null entry is a deletion marker (issue #90): skip it.
|
||||
let Some(p) = p else {
|
||||
continue;
|
||||
};
|
||||
if p.base_url.trim().is_empty() {
|
||||
problems.push(format!("provider '{name}': 'base_url' is required"));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
problems.push(format!(
|
||||
"provider name '{name}' must be a lowercase slug ([a-z0-9_-])"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
@@ -700,6 +1215,31 @@ mod tests {
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_token_prefers_explicit_binary() {
|
||||
// Interpreter launcher: without `detect`, the interpreter itself
|
||||
// would be probed (false "external" when node/python is on PATH).
|
||||
let a: AgentDef = serde_yaml::from_str(
|
||||
"name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
// No `detect`: falls back to the first token of run.
|
||||
let b: AgentDef =
|
||||
serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap();
|
||||
assert_eq!(b.detect_token().as_deref(), Some("python"));
|
||||
assert_eq!(b.first_token().as_deref(), Some("python"));
|
||||
// Plain binary command is unchanged.
|
||||
let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap();
|
||||
assert_eq!(c.detect_token().as_deref(), Some("claude"));
|
||||
// The embedded catalog must stay valid with the new field.
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap();
|
||||
assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap();
|
||||
assert_eq!(nano.detect_token().as_deref(), Some("nanobot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_paths() {
|
||||
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
//! Cost tracking (issue #49): collect token usage exposed by agents, price
|
||||
//! it against the configured model sheet, and aggregate per agent/session.
|
||||
//! No runtime dependency is added — everything is std + the existing config.
|
||||
|
||||
use crate::config::{self, CostModel};
|
||||
use crate::events::{self, Event, EventKind};
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Token usage collected from an agent.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
|
||||
pub struct Usage {
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
}
|
||||
|
||||
/// One aggregated cost row (per agent, or global totals).
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct CostStats {
|
||||
pub agent: String,
|
||||
pub sessions: u64,
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
pub cost_usd: f64,
|
||||
}
|
||||
|
||||
/// Price the usage against a model of the sheet (issue #49).
|
||||
/// Unknown models fall back to the first model of the sheet.
|
||||
pub fn estimate_cost(
|
||||
model: Option<&str>,
|
||||
usage: Usage,
|
||||
sheet: &BTreeMap<String, CostModel>,
|
||||
) -> f64 {
|
||||
let m = model
|
||||
.and_then(|k| sheet.get(k))
|
||||
.or_else(|| sheet.values().next());
|
||||
match m {
|
||||
Some(cm) => {
|
||||
usage.tokens_in as f64 * cm.input_per_mtok / 1_000_000.0
|
||||
+ usage.tokens_out as f64 * cm.output_per_mtok / 1_000_000.0
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the usage an agent exposed for its latest session (issue #49).
|
||||
///
|
||||
/// Supported sources today:
|
||||
/// - Claude Code: `~/.claude/projects/*/<session>.jsonl` — the last JSON
|
||||
/// line carrying an `usage` object with `input_tokens`/`output_tokens`.
|
||||
///
|
||||
/// Returns None when the agent exposes nothing (safe, silent).
|
||||
pub fn collect_usage(agent: &str, cwd: Option<&str>) -> Option<Usage> {
|
||||
match agent {
|
||||
"claude-code" => claude_usage(cwd),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code stores one JSONL per session under ~/.claude/projects/<enc>/.
|
||||
/// The final line with a "usage" object holds the cumulative token counts.
|
||||
fn claude_usage(cwd: Option<&str>) -> Option<Usage> {
|
||||
let root = config::home_dir()?.join(".claude/projects");
|
||||
let mut candidates: Vec<(String, PathBuf)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&root).ok()?.flatten() {
|
||||
let dir = entry.path();
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for f in std::fs::read_dir(&dir).ok()?.flatten() {
|
||||
let p = f.path();
|
||||
if p.extension().and_then(|e| e.to_str()) == Some("jsonl") {
|
||||
if let Ok(mtime) = f.metadata().and_then(|m| m.modified()) {
|
||||
candidates.push((mtime_key(mtime), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort();
|
||||
let last = candidates.last()?.1.clone();
|
||||
parse_claude_file(&last, cwd)
|
||||
}
|
||||
|
||||
fn mtime_key(t: std::time::SystemTime) -> String {
|
||||
format!(
|
||||
"{:?}",
|
||||
t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a Claude Code session file: the last line whose "usage" object has
|
||||
/// input/output token counts. Filters by cwd when given (the file embeds it
|
||||
/// as `cwd` on summary lines).
|
||||
fn parse_claude_file(path: &std::path::Path, cwd: Option<&str>) -> Option<Usage> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let mut usage = None;
|
||||
for line in text.lines() {
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
if v.get("usage").is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(want) = cwd {
|
||||
if v.get("cwd").and_then(|c| c.as_str()) != Some(want) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let u = v.get("usage")?;
|
||||
let tin = u.get("input_tokens").and_then(|n| n.as_u64())?;
|
||||
let tout = u.get("output_tokens").and_then(|n| n.as_u64())?;
|
||||
usage = Some(Usage {
|
||||
tokens_in: tin,
|
||||
tokens_out: tout,
|
||||
});
|
||||
}
|
||||
usage
|
||||
}
|
||||
|
||||
/// Record a session's usage: persist it on the session record and emit a
|
||||
/// `cost` event (axe 8 journal). Called when an agent session finishes.
|
||||
pub fn record_session_usage(
|
||||
app: &crate::app::App,
|
||||
agent: &str,
|
||||
session_id: &str,
|
||||
cwd: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let Some(usage) = collect_usage(agent, cwd) else {
|
||||
return Ok(());
|
||||
};
|
||||
let model = app
|
||||
.catalog
|
||||
.resolve(agent)
|
||||
.and_then(|a| a.cost_model.as_deref());
|
||||
let sheet = app
|
||||
.config
|
||||
.settings
|
||||
.cost_models
|
||||
.clone()
|
||||
.unwrap_or_else(config::default_cost_models);
|
||||
let cost = estimate_cost(model, usage, &sheet);
|
||||
|
||||
// Persist on the session record.
|
||||
let mut sf = crate::sessions::load(app)?;
|
||||
if let Some(r) = sf.sessions.get_mut(session_id) {
|
||||
r.tokens_in = Some(usage.tokens_in);
|
||||
r.tokens_out = Some(usage.tokens_out);
|
||||
r.cost_usd = Some(cost);
|
||||
crate::sessions::save(app, &sf)?;
|
||||
}
|
||||
|
||||
// Journal a dedicated event (stable --json contract, additive fields).
|
||||
app.emit(
|
||||
&Event::now(EventKind::Cost)
|
||||
.with_agent(agent.to_string())
|
||||
.with_session(session_id)
|
||||
.with_usage(usage.tokens_in, usage.tokens_out)
|
||||
.with_cost(cost),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aggregate cost events into per-agent rows (issue #49). `--json` output
|
||||
/// stays stable: agent, sessions, tokens_in, tokens_out, cost_usd.
|
||||
pub fn aggregate(
|
||||
app: &crate::app::App,
|
||||
period: Option<&str>,
|
||||
) -> Result<Vec<CostStats>> {
|
||||
let cut = crate::commands::stats_cmd::cutoff(period)?;
|
||||
let events = events::read_events(&app.events_dir(), 0);
|
||||
let mut map: BTreeMap<String, CostStats> = BTreeMap::new();
|
||||
for ev in events {
|
||||
if ev.kind != EventKind::Cost {
|
||||
continue;
|
||||
}
|
||||
if let Some(cut) = &cut {
|
||||
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) {
|
||||
if ts.with_timezone(&chrono::Utc) < *cut {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let agent = ev.agent.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
let row = map.entry(agent.clone()).or_default();
|
||||
row.agent = agent;
|
||||
row.sessions += 1;
|
||||
row.tokens_in += ev.tokens_in.unwrap_or(0);
|
||||
row.tokens_out += ev.tokens_out.unwrap_or(0);
|
||||
row.cost_usd += ev.cost_usd.unwrap_or(0.0);
|
||||
}
|
||||
Ok(map.into_values().collect())
|
||||
}
|
||||
|
||||
/// Total across every row (used by the weekly/monthly report).
|
||||
pub fn totals(rows: &[CostStats]) -> CostStats {
|
||||
let mut t = CostStats {
|
||||
agent: "total".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
for r in rows {
|
||||
t.sessions += r.sessions;
|
||||
t.tokens_in += r.tokens_in;
|
||||
t.tokens_out += r.tokens_out;
|
||||
t.cost_usd += r.cost_usd;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn estimate_cost_uses_model_prices() {
|
||||
let sheet = config::default_cost_models();
|
||||
// 1M input @ $3 + 500k output @ $15 = 3 + 7.5 = $10.5
|
||||
let u = Usage {
|
||||
tokens_in: 1_000_000,
|
||||
tokens_out: 500_000,
|
||||
};
|
||||
let c = estimate_cost(Some("claude-sonnet-4"), u, &sheet);
|
||||
assert!((c - 10.5).abs() < 1e-9, "got {c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_cost_falls_back_to_first_model() {
|
||||
let sheet = config::default_cost_models();
|
||||
let c = estimate_cost(Some("nope-model"), Usage { tokens_in: 0, tokens_out: 0 }, &sheet);
|
||||
assert_eq!(c, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_cost_unknown_sheet_is_zero() {
|
||||
let sheet = BTreeMap::new();
|
||||
let c = estimate_cost(None, Usage { tokens_in: 1000, tokens_out: 1000 }, &sheet);
|
||||
assert_eq!(c, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_claude_file_extracts_last_usage() {
|
||||
let dir = std::env::temp_dir().join(format!("am-costs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("session.jsonl");
|
||||
std::fs::write(
|
||||
&f,
|
||||
concat!(
|
||||
"{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5}},\"cwd\":\"/x\"}\n",
|
||||
"{\"type\":\"summary\",\"usage\":{\"input_tokens\":100,\"output_tokens\":40},\"cwd\":\"/x\"}\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let u = parse_claude_file(&f, Some("/x")).unwrap();
|
||||
assert_eq!(u.tokens_in, 100);
|
||||
assert_eq!(u.tokens_out, 40);
|
||||
// Wrong cwd → filtered out.
|
||||
assert!(parse_claude_file(&f, Some("/other")).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_sums_per_agent() {
|
||||
let app = test_app("costs-agg");
|
||||
app.emit(
|
||||
&Event::now(EventKind::Cost)
|
||||
.with_agent("a".to_string())
|
||||
.with_usage(10, 5)
|
||||
.with_cost(0.5),
|
||||
);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Cost)
|
||||
.with_agent("a".to_string())
|
||||
.with_usage(20, 10)
|
||||
.with_cost(1.0),
|
||||
);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Cost)
|
||||
.with_agent("b".to_string())
|
||||
.with_usage(100, 50)
|
||||
.with_cost(2.0),
|
||||
);
|
||||
let rows = aggregate(&app, None).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
let a = rows.iter().find(|r| r.agent == "a").unwrap();
|
||||
assert_eq!(a.sessions, 2);
|
||||
assert_eq!(a.tokens_in, 30);
|
||||
assert_eq!(a.tokens_out, 15);
|
||||
assert!((a.cost_usd - 1.5).abs() < 1e-9);
|
||||
let t = totals(&rows);
|
||||
assert!((t.cost_usd - 3.5).abs() < 1e-9);
|
||||
assert_eq!(t.sessions, 3);
|
||||
}
|
||||
|
||||
fn test_app(tag: &str) -> crate::app::App {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
" confirm_before_run: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
use clap::Parser;
|
||||
let cli = crate::cli::Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
cfg.to_str().unwrap(),
|
||||
"list",
|
||||
]);
|
||||
let mut app = crate::app::App::from_cli(cli).expect("app should build");
|
||||
app.paths.state_file = dir.path().join(format!("state-{tag}.json"));
|
||||
app.paths.log_dir = dir.path().join("logs");
|
||||
app
|
||||
}
|
||||
}
|
||||
+40
-2
@@ -76,8 +76,7 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
||||
let empty = BTreeMap::new();
|
||||
if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) {
|
||||
if out.code == 0 {
|
||||
let combined = format!("{}
|
||||
{}", out.stdout, out.stderr);
|
||||
let combined = format!("{}\n{}", out.stdout, out.stderr);
|
||||
if let Some(v) = crate::version::find_version(&combined) {
|
||||
detail = format!("{} (v{v})", path.display());
|
||||
}
|
||||
@@ -113,6 +112,45 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
||||
}
|
||||
}
|
||||
|
||||
// --- local model runtimes (issue #72) -------------------------------
|
||||
for runtime in ["ollama", "llama-server"] {
|
||||
if let Some(path) = runner.which(runtime) {
|
||||
let mut detail = path.display().to_string();
|
||||
let empty = BTreeMap::new();
|
||||
if let Ok(out) = runner.run(runtime, &["--version".to_string()], &empty, None, true) {
|
||||
if out.code == 0 {
|
||||
let combined = format!("{}\n{}", out.stdout, out.stderr);
|
||||
if let Some(v) = crate::version::find_version(&combined) {
|
||||
detail = format!("{} (v{})", path.display(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
// For ollama, also check that the server answers.
|
||||
let status = if runtime == "ollama" {
|
||||
match runner.run(runtime, &["ls".to_string()], &empty, None, true) {
|
||||
Ok(out) if out.code == 0 => Status::Ok,
|
||||
_ => {
|
||||
detail.push_str(" — server does not answer (ollama serve?)");
|
||||
Status::Warn
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
checks.push(Check {
|
||||
name: runtime.to_string(),
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
} else {
|
||||
checks.push(Check {
|
||||
name: runtime.to_string(),
|
||||
status: Status::Ok,
|
||||
detail: "not installed (optional)".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- configuration ---------------------------------------------------
|
||||
match &app.config_origin {
|
||||
Some(path) => checks.push(Check {
|
||||
|
||||
+61
-2
@@ -32,6 +32,29 @@ pub enum EventKind {
|
||||
Shell,
|
||||
/// Personal annotations: favorite, note, tags (issue #39).
|
||||
Annotate,
|
||||
/// Local model runtime action: prune of unused models (issue #73).
|
||||
Model,
|
||||
/// Remote catalog action: update or add of an external catalog (issue #60).
|
||||
Catalog,
|
||||
/// Pre-update backup created (issue #64).
|
||||
Backup,
|
||||
/// Rollback of a previous backup (issue #64).
|
||||
Rollback,
|
||||
/// Derived index pruned (sessions retention, issue #53).
|
||||
Prune,
|
||||
/// Session usage recorded: tokens + estimated cost (issue #49).
|
||||
Cost,
|
||||
/// Threshold alert from 'am monitor' (issue #50).
|
||||
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 {
|
||||
@@ -49,6 +72,17 @@ impl EventKind {
|
||||
EventKind::Repl => "repl",
|
||||
EventKind::Shell => "shell",
|
||||
EventKind::Annotate => "annotate",
|
||||
EventKind::Model => "model",
|
||||
EventKind::Catalog => "catalog",
|
||||
EventKind::Backup => "backup",
|
||||
EventKind::Rollback => "rollback",
|
||||
EventKind::Prune => "prune",
|
||||
EventKind::Cost => "cost",
|
||||
EventKind::Alert => "alert",
|
||||
EventKind::Lab => "lab",
|
||||
EventKind::Provider => "provider",
|
||||
EventKind::Sandbox => "sandbox",
|
||||
EventKind::ShellAi => "shell_ai",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +114,15 @@ pub struct Event {
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub duration_s: Option<u64>,
|
||||
/// Input tokens billed for the session (issue #49).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tokens_in: Option<u64>,
|
||||
/// Output tokens billed for the session (issue #49).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tokens_out: Option<u64>,
|
||||
/// Estimated cost in USD (issue #49).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
@@ -98,6 +141,9 @@ impl Event {
|
||||
env_keys: Vec::new(),
|
||||
exit_code: None,
|
||||
duration_s: None,
|
||||
tokens_in: None,
|
||||
tokens_out: None,
|
||||
cost_usd: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +192,21 @@ impl Event {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_duration(mut self, secs: u64) -> Self {
|
||||
self.duration_s = Some(secs);
|
||||
pub fn with_duration(mut self, d: u64) -> Self {
|
||||
self.duration_s = Some(d);
|
||||
self
|
||||
}
|
||||
|
||||
/// Record token usage (issue #49).
|
||||
pub fn with_usage(mut self, tokens_in: u64, tokens_out: u64) -> Self {
|
||||
self.tokens_in = Some(tokens_in);
|
||||
self.tokens_out = Some(tokens_out);
|
||||
self
|
||||
}
|
||||
|
||||
/// Record the estimated cost in USD (issue #49).
|
||||
pub fn with_cost(mut self, usd: f64) -> Self {
|
||||
self.cost_usd = Some(usd);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am — {{Vue d'ensemble}}</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; animation:pulse 2.4s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
/* ---------- 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; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.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; margin-bottom:14px; }
|
||||
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
|
||||
.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)); transition:width .5s ease; }
|
||||
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
canvas#trend-canvas { width:100%; height:150px; display:block; }
|
||||
.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; white-space:nowrap; }
|
||||
.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; }
|
||||
.view { display:none; } .view.active { display:block; }
|
||||
.err-bar { background:rgba(248,113,113,.12); border:1px solid rgba(248,113,113,.35); color:var(--err); border-radius:8px; padding:10px 14px; margin-bottom:14px; font-size:12.5px; display:none; }
|
||||
@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>v<span id="side-version">—</span> · <span id="side-lang">{{serveur local}}</span></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 id="view-title">{{Vue d'ensemble}}</h1>
|
||||
<div class="sub"><span>{{Dernière mise à jour}}</span> : <span id="last-update">—</span></div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="badge live">{{serveur local}}</span>
|
||||
<span class="badge">127.0.0.1</span>
|
||||
<button class="btn ghost" onclick="loadAll()">↻ {{Actualiser}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="err-bar" id="err-bar"></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" id="kpi-installed">—</div><div class="delta" id="kpi-installed-delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">⚡</span><div class="label">{{En cours}}</div><div class="value" id="kpi-running">—</div><div class="delta" id="kpi-running-delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-sessions">—</div><div class="delta" id="kpi-sessions-delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">📜</span><div class="label">{{Événements (24 h)}}</div><div class="value" id="kpi-events">—</div><div class="delta" id="kpi-events-delta"></div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>{{Top agents — sessions (7 j)}}</h3>
|
||||
<div id="top-agents"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>{{Activité — sessions par jour (14 j)}}</h3>
|
||||
<canvas id="trend-canvas"></canvas>
|
||||
<div class="trend-axis"><span id="axis-a"></span><span id="axis-b"></span><span id="axis-c"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>{{Activité récente}}</h3>
|
||||
<ul class="tl" id="recent-activity"></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 id="running-sessions"></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>{{Lancements}}</th><th>{{Durée}}</th><th>{{Durée moyenne}}</th><th>{{Échecs}}</th><th>{{Dernière activité}}</th></tr></thead>
|
||||
<tbody id="stats-body"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="stats-empty" style="display:none">{{Aucune activité enregistrée}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SESSIONS ===== -->
|
||||
<section class="view" id="sessions">
|
||||
<div class="panel">
|
||||
<h3>Sessions</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>{{Démarrée}}</th><th>{{Fin}}</th></tr></thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="sessions-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" id="events-list"></ul>
|
||||
<div class="empty-note" id="events-empty" style="display:none">{{Aucun événement}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PROJECTS ===== -->
|
||||
<section class="view" id="projects">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="emoji">🗂</span><div class="label">{{Projets suivis}}</div><div class="value" id="kpi-projects">—</div><div class="delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-proj-sessions">—</div><div class="delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">🕒</span><div class="label">{{Temps total}}</div><div class="value" id="kpi-proj-time">—</div><div class="delta"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>{{Projets}}</h3>
|
||||
<table>
|
||||
<thead><tr><th>{{Projet}}</th><th>{{Agent}}</th><th>Sessions</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Dernière activité}}</th></tr></thead>
|
||||
<tbody id="projects-body"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="projects-empty" style="display:none">{{Aucun projet suivi}}</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Injected by the server: localized status labels (fr/en, gettext keys).
|
||||
window.I18N = {{i18n_bundle}};
|
||||
|
||||
const state = { overview: null, stats: null, sessions: null, events: null, projects: null, health: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return String(iso).slice(11, 16);
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
function fmtDate(iso) { return iso ? String(iso).slice(0, 10) : '—'; }
|
||||
function fmtDur(s) {
|
||||
if (s == null) return '—';
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) return h + 'h' + String(m).padStart(2, '0') + 'm';
|
||||
if (m > 0) return m + 'm';
|
||||
return s + 's';
|
||||
}
|
||||
function shortRoot(root) {
|
||||
if (!root) return '—';
|
||||
const parts = String(root).split(/[\\/]/).filter(Boolean);
|
||||
return esc(parts[parts.length - 1] || root);
|
||||
}
|
||||
const STATUS_CLASS = { running: 'run', stopped: 'ok', failed: 'fail', interrupted: 'mut' };
|
||||
function statusPill(s) {
|
||||
const cls = STATUS_CLASS[s] || 'ok';
|
||||
const label = (window.I18N && window.I18N[s]) || s;
|
||||
return '<span class="pill ' + cls + '">' + esc(label) + '</span>';
|
||||
}
|
||||
const KIND_COLORS = {
|
||||
start:'#38bdf8', run:'#38bdf8', restart:'#38bdf8', cost:'#38bdf8',
|
||||
stop:'#34d399', shell:'#34d399', repl:'#34d399',
|
||||
install:'#a78bfa', update:'#a78bfa', uninstall:'#a78bfa', config:'#a78bfa',
|
||||
annotate:'#a78bfa', backup:'#a78bfa', rollback:'#a78bfa', model:'#a78bfa',
|
||||
catalog:'#a78bfa', prune:'#a78bfa', lab:'#a78bfa',
|
||||
doctor:'#f59e0b', alert:'#f87171',
|
||||
};
|
||||
function eventDetail(e) {
|
||||
if (e.args && e.args.length) return esc(e.args.join(' '));
|
||||
if (e.reason) return esc(e.reason);
|
||||
return '';
|
||||
}
|
||||
|
||||
async function jget(url) {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [ov, st, se, ev, pr, h] = await Promise.all([
|
||||
jget('/api/overview'), jget('/api/stats'), jget('/api/sessions'),
|
||||
jget('/api/events?limit=2000'), jget('/api/projects'), jget('/api/health'),
|
||||
]);
|
||||
state.overview = ov; state.stats = st; state.sessions = se;
|
||||
state.events = ev; state.projects = pr; state.health = h;
|
||||
$('err-bar').style.display = 'none';
|
||||
$('side-version').textContent = (h && h.version) || '—';
|
||||
renderAll();
|
||||
const t = new Date();
|
||||
$('last-update').textContent = t.toLocaleTimeString();
|
||||
} catch (e) {
|
||||
const bar = $('err-bar');
|
||||
bar.textContent = '{{erreur de chargement}} : ' + e.message;
|
||||
bar.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderOverview(); renderStats(); renderSessions(); renderEvents(); renderProjects();
|
||||
}
|
||||
|
||||
// ---------- Overview ----------
|
||||
function renderOverview() {
|
||||
const ov = state.overview || {};
|
||||
const counts = ov.counts || {};
|
||||
$('kpi-installed').textContent = counts.installed != null ? counts.installed : '—';
|
||||
$('kpi-running').textContent = counts.running != null ? counts.running : '—';
|
||||
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
const sessions = ov.sessions || [];
|
||||
const s7 = sessions.filter(s => now - new Date(s.started_at).getTime() <= 7 * DAY).length;
|
||||
const ev24 = (state.events || []).filter(e => now - new Date(e.ts).getTime() <= DAY).length;
|
||||
$('kpi-sessions').textContent = s7;
|
||||
$('kpi-events').textContent = ev24;
|
||||
$('kpi-sessions-delta').textContent = sessions.length + ' total';
|
||||
$('kpi-events-delta').textContent = (state.events || []).length + ' total';
|
||||
|
||||
// Top agents by launches (7d from the dashboard snapshot).
|
||||
const stats = (ov.stats || []).slice().sort((a, b) => b.launches - a.launches).slice(0, 6);
|
||||
const max = Math.max(1, ...stats.map(s => s.launches));
|
||||
$('top-agents').innerHTML = stats.length
|
||||
? stats.map(s =>
|
||||
'<div class="bar-row"><span class="name" title="' + esc(s.agent) + '">' + esc(s.agent) + '</span>' +
|
||||
'<div class="track"><div class="fill" style="width:' + Math.round(s.launches / max * 100) + '%"></div></div>' +
|
||||
'<span class="val">' + s.launches + '</span></div>').join('')
|
||||
: '<div class="empty-note">{{Aucune activité enregistrée}}</div>';
|
||||
|
||||
renderTrend();
|
||||
setAxis();
|
||||
|
||||
// Recent activity (newest first, already sorted by the server).
|
||||
const activity = (ov.activity || []).slice(0, 8);
|
||||
$('recent-activity').innerHTML = activity.length
|
||||
? activity.map(a => {
|
||||
const color = KIND_COLORS[a.kind] || '#8b98a5';
|
||||
const who = a.agent ? '<span class="a">' + esc(a.agent) + '</span> ' : '';
|
||||
return '<li><span class="dot" style="background:' + color + '"></span>' +
|
||||
'<span class="t">' + who + esc(a.detail) + '</span>' +
|
||||
'<span class="m">' + fmtTime(a.ts) + '</span></li>';
|
||||
}).join('')
|
||||
: '<li class="empty-note">{{Aucune activité enregistrée}}</li>';
|
||||
|
||||
// Running sessions.
|
||||
const running = sessions.filter(s => s.status === 'running');
|
||||
$('running-sessions').innerHTML = running.length
|
||||
? running.map(s =>
|
||||
'<tr><td>' + esc(s.agent || s.kind) + '</td><td>' + esc(s.project || '—') + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.duration_s) + '</td><td class="mono">' + fmtTime(s.started_at) + '</td></tr>').join('')
|
||||
: '';
|
||||
$('running-empty').style.display = running.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function setAxis() {
|
||||
const d = new Date();
|
||||
const fmt = (off) => { const x = new Date(d); x.setDate(x.getDate() + off); return (x.getMonth() + 1) + '/' + x.getDate(); };
|
||||
$('axis-a').textContent = fmt(-13);
|
||||
$('axis-b').textContent = fmt(-7);
|
||||
$('axis-c').textContent = fmt(0);
|
||||
}
|
||||
|
||||
function renderTrend() {
|
||||
const canvas = $('trend-canvas');
|
||||
const wrap = canvas.parentElement;
|
||||
const W = Math.max(240, wrap.clientWidth - 4) || 480, H = 150;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
days.push({ key, n: 0 });
|
||||
}
|
||||
(state.events || []).forEach(e => {
|
||||
if (e.kind === 'start' || e.kind === 'run') {
|
||||
const k = String(e.ts).slice(0, 10);
|
||||
const day = days.find(d => d.key === k);
|
||||
if (day) day.n++;
|
||||
}
|
||||
});
|
||||
const max = Math.max(1, ...days.map(d => d.n));
|
||||
const pad = 16, base = H - 26, bw = (W - 2 * pad) / days.length;
|
||||
|
||||
// baseline + gridline
|
||||
ctx.strokeStyle = '#1d2733'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(pad, base + 0.5); ctx.lineTo(W - pad, base + 0.5); ctx.stroke();
|
||||
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, base);
|
||||
grad.addColorStop(0, '#3ddc97'); grad.addColorStop(1, '#38bdf8');
|
||||
days.forEach((d, i) => {
|
||||
const x = pad + i * bw, h = Math.round((d.n / max) * (base - 18));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(x + 2, base - h, bw - 4, h);
|
||||
if (d.n > 0) {
|
||||
ctx.fillStyle = '#0b0f14'; ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(d.n), x + bw / 2, base - h - 4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Stats ----------
|
||||
function renderStats() {
|
||||
const rows = state.stats || [];
|
||||
$('stats-body').innerHTML = rows.length
|
||||
? rows.slice().sort((a, b) => b.launches - a.launches).map(s =>
|
||||
'<tr><td>' + esc(s.agent) + '</td><td>' + s.launches + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.total_duration_s) + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.avg_duration_s) + '</td>' +
|
||||
'<td>' + (s.failures > 0 ? '<span class="pill fail">' + s.failures + '</span>' : '<span class="pill ok">0</span>') + '</td>' +
|
||||
'<td class="mono">' + fmtDate(s.last_used) + ' ' + fmtTime(s.last_used) + '</td></tr>').join('')
|
||||
: '';
|
||||
$('stats-empty').style.display = rows.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
function renderSessions() {
|
||||
const rows = state.sessions || [];
|
||||
$('sessions-body').innerHTML = rows.length
|
||||
? rows.map(s =>
|
||||
'<tr><td class="mono">' + esc(s.id) + '</td><td>' + esc(s.agent || s.kind) + '</td>' +
|
||||
'<td>' + esc(s.project || '—') + '</td><td>' + statusPill(s.status) + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.duration_s) + '</td>' +
|
||||
'<td class="mono">' + fmtDate(s.started_at) + ' ' + fmtTime(s.started_at) + '</td>' +
|
||||
'<td class="mono">' + fmtTime(s.ended_at) + '</td></tr>').join('')
|
||||
: '';
|
||||
filterSessions(($('sess-search') || {}).value || '');
|
||||
$('sessions-empty').style.display = 'none';
|
||||
}
|
||||
|
||||
function filterSessions(q) {
|
||||
q = (q || '').toLowerCase();
|
||||
let shown = 0;
|
||||
document.querySelectorAll('#sessions-body tr').forEach(tr => {
|
||||
const ok = tr.textContent.toLowerCase().includes(q);
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
if (ok) shown++;
|
||||
});
|
||||
$('sessions-empty').style.display = shown ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
function renderEvents() {
|
||||
const rows = state.events || [];
|
||||
$('events-list').innerHTML = rows.length
|
||||
? rows.map(e => {
|
||||
const color = KIND_COLORS[e.kind] || '#8b98a5';
|
||||
const who = e.agent ? '<span class="a">' + esc(e.agent) + '</span> ' : '';
|
||||
return '<li><span class="dot" style="background:' + color + '"></span>' +
|
||||
'<span class="t"><span class="a">' + esc(e.kind) + '</span> ' + who + eventDetail(e) + '</span>' +
|
||||
'<span class="m">' + fmtTime(e.ts) + '</span></li>';
|
||||
}).join('')
|
||||
: '';
|
||||
$('events-empty').style.display = rows.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Projects ----------
|
||||
function renderProjects() {
|
||||
const rows = state.projects || [];
|
||||
$('kpi-projects').textContent = rows.length;
|
||||
$('kpi-proj-sessions').textContent = rows.reduce((a, p) => a + (p.sessions || 0), 0);
|
||||
$('kpi-proj-time').textContent = fmtDur(rows.reduce((a, p) => a + (p.total_duration_s || 0), 0));
|
||||
$('projects-body').innerHTML = rows.length
|
||||
? rows.map(p =>
|
||||
'<tr><td><b>' + shortRoot(p.root) + '</b></td>' +
|
||||
'<td>' + (p.agents || []).map(esc).join(' · ') + '</td>' +
|
||||
'<td>' + (p.sessions || 0) + '</td><td>' + (p.launches || 0) + '</td>' +
|
||||
'<td class="mono">' + fmtDur(p.total_duration_s) + '</td>' +
|
||||
'<td class="mono">' + fmtDate(p.last_activity) + ' ' + fmtTime(p.last_activity) + '</td></tr>').join('')
|
||||
: '';
|
||||
$('projects-empty').style.display = rows.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Navigation ----------
|
||||
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');
|
||||
$('view-title').textContent = TITLES[v];
|
||||
if (v === 'overview') renderTrend(); // canvas needs the visible size
|
||||
});
|
||||
});
|
||||
window.addEventListener('resize', () => { if (state.overview) renderTrend(); });
|
||||
|
||||
loadAll();
|
||||
setInterval(loadAll, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+551
-40
@@ -105,6 +105,12 @@ const GLOBAL_FLAGS: &[HelpFlag] = &[
|
||||
value: "THEME",
|
||||
desc: "Color theme for the output (see 'am help theme' for the list)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--lang",
|
||||
value: "LANG",
|
||||
desc: "Output language: fr (default) or en (issue #74)",
|
||||
},
|
||||
];
|
||||
|
||||
const START_FLAGS: &[HelpFlag] = &[
|
||||
@@ -138,6 +144,36 @@ const START_FLAGS: &[HelpFlag] = &[
|
||||
value: "",
|
||||
desc: "Send a desktop notification once the agent has started",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--parallel",
|
||||
value: "",
|
||||
desc: "Start every group member back-to-back without waiting (issue #57)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--container",
|
||||
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] = &[
|
||||
@@ -181,12 +217,16 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "sessions",
|
||||
category: "Commands",
|
||||
usage: "sessions {flags} [agent]",
|
||||
about: "List the sessions of every agent and of the interactive shell.",
|
||||
search_terms: &["session", "history", "log"],
|
||||
about: "List sessions; export one as JSON; enforce retention on old ones.",
|
||||
search_terms: &["session", "history", "log", "export", "retention", "purge", "resume"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only sessions of this project" },
|
||||
HelpFlag { short: "", long: "--status", value: "STATUS", desc: "Only sessions with this status (running, stopped, failed, interrupted)" },
|
||||
HelpFlag { short: "", long: "--show", value: "ID", desc: "Show one session in detail (summary + log excerpt)" },
|
||||
HelpFlag { short: "", long: "--export", value: "ID", desc: "Export a session (metadata + commands + log excerpt) to JSON" },
|
||||
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Export destination (default ./am-session-<ID>.json)" },
|
||||
HelpFlag { short: "", long: "--retention", value: "DAYS", desc: "Purge sessions finished more than N days ago (settings default 90)" },
|
||||
HelpFlag { short: "", long: "--resume", value: "ID", desc: "Relaunch a finished agent session with its recorded arguments" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
@@ -195,17 +235,57 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "List recent sessions.", code: "sessions" },
|
||||
HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" },
|
||||
HelpExample { desc: "Export one session.", code: "sessions --export 20260815_143926_a1b2c3" },
|
||||
HelpExample { desc: "Purge sessions older than 30 days.", code: "sessions --retention 30" },
|
||||
HelpExample { desc: "Resume a finished session.", code: "sessions --resume 20260815_143926_a1b2c3" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "sync",
|
||||
category: "Commands",
|
||||
usage: "sync {flags}",
|
||||
about: "Push the state (state.json, journal, history) into the configured git repository (issue #66).",
|
||||
search_terms: &["git", "backup", "push", "sync_repo", "sauvegarde"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--message", value: "MESSAGE", desc: "Commit message (default: 'am sync — state update')" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Commit and push the state.", code: "sync" },
|
||||
HelpExample { desc: "Custom commit message.", code: "sync --message \"daily backup\"" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "migrate",
|
||||
category: "Commands",
|
||||
usage: "migrate {flags} [bundle]",
|
||||
about: "Transfer the installation and state to another machine: export a .amx bundle, import it and verify with doctor (issue #68).",
|
||||
search_terms: &["transfer", "machine", "bundle", "export", "import", "migration"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--export", value: "", desc: "Export the bundle (default when no bundle path is given)" },
|
||||
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Bundle path for export (default agent-manager-migrate.amx)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "bundle", typ: "path", desc: "Bundle to import (with confirmation + doctor post-import)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Export the bundle.", code: "migrate --export --output backup.amx" },
|
||||
HelpExample { desc: "Import on machine B.", code: "migrate backup.amx" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "stats",
|
||||
category: "Commands",
|
||||
usage: "stats {flags} [agent]",
|
||||
about: "Show usage statistics computed from the event journal.",
|
||||
search_terms: &["usage", "metrics", "duration"],
|
||||
about: "Usage statistics from the event journal; --costs prices the token usage.",
|
||||
search_terms: &["usage", "cost", "tokens", "spend", "period"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" },
|
||||
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "7d, 30d, 90d or all" },
|
||||
HelpFlag { short: "", long: "--costs", value: "", desc: "Show token usage and estimated spend per agent (issue #49)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
@@ -213,8 +293,131 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Global usage statistics.", code: "stats" },
|
||||
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" },
|
||||
HelpExample { desc: "Global statistics.", code: "stats" },
|
||||
HelpExample { desc: "Last 30 days.", code: "stats --period 30d" },
|
||||
HelpExample { desc: "Estimated spend.", code: "stats --period 30d --costs" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "monitor",
|
||||
category: "Commands",
|
||||
usage: "monitor {flags}",
|
||||
about: "Real-time TUI of the managed processes (CPU, RSS, uptime) with threshold alerts.",
|
||||
search_terms: &["process", "cpu", "memory", "rss", "alert", "top", "surveillance"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--interval", value: "SECONDS", desc: "Refresh interval (default 2)" },
|
||||
HelpFlag { short: "", long: "--json", value: "", desc: "Print one JSON document per tick (for scripts)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Live TUI (q / Esc quits).", code: "monitor" },
|
||||
HelpExample { desc: "Refresh every 5s.", code: "monitor --interval 5" },
|
||||
HelpExample { desc: "JSON stream for scripts.", code: "monitor --json --interval 10" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "web",
|
||||
category: "Commands",
|
||||
usage: "web {flags}",
|
||||
about: "Local read-only web dashboard: overview, charts, sessions, events, projects (issue #54). Serves an embedded page and a JSON API with the same stable contracts as the CLI --json outputs; bound to 127.0.0.1 only.",
|
||||
search_terms: &["web", "dashboard", "html", "http", "api", "graph", "chart", "navigateur", "browser", "port"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--port", value: "PORT", desc: "Port to listen on (default 7878)" },
|
||||
HelpFlag { short: "", long: "--no-open", value: "", desc: "Do not open the browser automatically" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Open the dashboard in the browser.", code: "web" },
|
||||
HelpExample { desc: "Serve on another port, no browser.", code: "web --port 9090 --no-open" },
|
||||
HelpExample { desc: "API routes: /api/health, /api/overview, /api/stats, /api/sessions, /api/events, /api/projects.", code: "curl http://127.0.0.1:7878/api/health" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "history",
|
||||
category: "Commands",
|
||||
usage: "history {flags} [range]",
|
||||
about: "Structured history of every REPL command, with search, re-run and playbook export (issue #51).",
|
||||
search_terms: &["history", "search", "rerun", "playbook", "save", "replay"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--kind", value: "KIND", desc: "Only commands of this kind (am or shell)" },
|
||||
HelpFlag { short: "", long: "--cwd", value: "DIR", desc: "Only commands run in this directory" },
|
||||
HelpFlag { short: "", long: "--search", value: "TEXT", desc: "Full-text search in the command line" },
|
||||
HelpFlag { short: "", long: "--failed", value: "", desc: "Only failed commands" },
|
||||
HelpFlag { short: "", long: "--session", value: "ID", desc: "Only commands of this session" },
|
||||
HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum entries (default 100)" },
|
||||
HelpFlag { short: "", long: "--rerun", value: "N", desc: "Re-run the Nth most recent entry" },
|
||||
HelpFlag { short: "", long: "--save", value: "FILE", desc: "Export the range as a playbook YAML (issue #51)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "range", typ: "from..to", desc: "1-based inclusive range, newest first (e.g. 12..25)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "List the last commands.", code: "history" },
|
||||
HelpExample { desc: "Re-run the 5th most recent entry.", code: "history --rerun 5" },
|
||||
HelpExample { desc: "Save entries 12..25 as a playbook.", code: "history 12..25 --save deploy.yaml" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "playbook",
|
||||
category: "Commands",
|
||||
usage: "playbook <file> {flags}",
|
||||
about: "Replay a saved playbook step by step with confirmation; stops with a non-zero exit code on failure (issue #51).",
|
||||
search_terms: &["playbook", "replay", "rejouer", "steps", "automation"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--var", value: "NAME=VALUE", desc: "Variable value, repeatable ({{name}} in the steps)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "file", typ: "path", desc: "Playbook file (bare name resolves under state_dir/playbooks/)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Replay with confirmation for each step.", code: "playbook deploy.yaml" },
|
||||
HelpExample { desc: "Replay without asking, with a variable.", code: "playbook deploy.yaml --yes --var agent=claude-code" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "lab",
|
||||
category: "Commands",
|
||||
usage: "lab --agents a,b --task <file> {flags}",
|
||||
about: "Benchmark: run the same task on several agents and compare duration, exit code, output and estimated cost (issue #62).",
|
||||
search_terms: &["benchmark", "compare", "comparer", "bench", "perf"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--agents", value: "LIST", desc: "Comma-separated agent names (or aliases) to benchmark (required)" },
|
||||
HelpFlag { short: "", long: "--task", value: "FILE", desc: "Task file (bare name resolves under state_dir/lab/, .yaml appended)" },
|
||||
HelpFlag { short: "", long: "--parallel", value: "", desc: "Run every agent concurrently instead of one after another" },
|
||||
HelpFlag { short: "", long: "--timeout", value: "SECS", desc: "Override the task timeout in seconds" },
|
||||
HelpFlag { short: "", long: "--list", value: "", desc: "List the task files of the lab directory" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Run the task sequentially on two agents.", code: "lab --agents claude-code,aider --task hello.yaml" },
|
||||
HelpExample { desc: "Machine-readable comparative report.", code: "lab --agents claude-code,codex --task bench.yaml --json --parallel" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "plugins",
|
||||
category: "Commands",
|
||||
usage: "plugins {flags}",
|
||||
about: "List the event plugins of the state directory and test one against a synthetic event (issue #75).",
|
||||
search_terms: &["plugin", "extension", "hooks", "notify", "webhook"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--test", value: "NAME", desc: "Run one plugin on a synthetic event and print its response (CI hook)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: Some(("JSON (stdin)", "JSON object (stdout)")),
|
||||
examples: &[
|
||||
HelpExample { desc: "List the installed plugins.", code: "plugins" },
|
||||
HelpExample { desc: "Exercise the contract of one plugin.", code: "plugins --test notify" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -367,6 +570,96 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Supervise with auto-restart.", code: "watch claude-code --restart --notify" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "models",
|
||||
category: "Commands",
|
||||
usage: "models {flags}",
|
||||
about: "List the models of the local runtimes (ollama, llama.cpp, LM Studio) and prune unused ones.",
|
||||
search_terms: &["ollama", "llama", "lm studio", "model", "prune", "gguf"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--prune", value: "", desc: "Purge unused models instead of listing them" },
|
||||
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Only list what would be pruned, delete nothing" },
|
||||
HelpFlag { short: "", long: "--days", value: "DAYS", desc: "Consider a model unused after N days (default: 30)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "List the local models.", code: "models" },
|
||||
HelpExample { desc: "Preview the prune candidates.", code: "models --prune --dry-run" },
|
||||
HelpExample { desc: "Prune models unused for 60 days.", code: "models --prune --days 60" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "catalog",
|
||||
category: "Commands",
|
||||
usage: "catalog {flags} <update|add|list> [url]",
|
||||
about: "Manage remote agent catalogs: refresh the official one or register external catalogs by URL.",
|
||||
search_terms: &["remote", "url", "team", "share", "include"],
|
||||
flags: &[],
|
||||
subcommands: &[
|
||||
("update", "Fetch the official catalog, show the diff and register it"),
|
||||
("add", "Register an external catalog by URL"),
|
||||
("list", "List the registered remote catalogs"),
|
||||
],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Refresh the official catalog.", code: "catalog update" },
|
||||
HelpExample { desc: "Add a team catalog.", code: "catalog add https://git.dracodev.net/team/agents.yaml" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "suggest",
|
||||
category: "Commands",
|
||||
usage: "suggest <query>",
|
||||
about: "Recommend agents for a natural-language request, ranked by relevance and real usage.",
|
||||
search_terms: &["recommend", "recommendation", "find an agent", "what agent"],
|
||||
flags: &[],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Find an agent for Python.", code: "suggest un agent pour du Python" },
|
||||
HelpExample { desc: "Machine-readable output.", code: "suggest frontend framework --json" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "audit",
|
||||
category: "Commands",
|
||||
usage: "audit {flags}",
|
||||
about: "Who changed what, when: config checksums vs the event journal, manual edits flagged.",
|
||||
search_terms: &["security", "trace", "checksum", "who changed", "history config"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--json", value: "", desc: "Stable machine-readable report" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Run the config audit.", code: "audit" },
|
||||
HelpExample { desc: "Machine-readable report.", code: "audit --json" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "update",
|
||||
category: "Commands",
|
||||
usage: "update {flags} <agent|--all>",
|
||||
about: "Update installed agents; every update is backed up first and can be undone.",
|
||||
search_terms: &["upgrade", "rollback", "restore", "backup"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--all", value: "", desc: "Update every managed agent (one backup for the batch)" },
|
||||
HelpFlag { short: "", long: "--rollback", value: "[POINT]", desc: "Restore a pre-update backup ('list' shows them, default: latest)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Update one agent (backup taken first).", code: "update claude-code" },
|
||||
HelpExample { desc: "List the available backups.", code: "update --rollback list" },
|
||||
HelpExample { desc: "Undo the last update.", code: "update --rollback" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "alias",
|
||||
category: "Commands",
|
||||
@@ -389,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 {
|
||||
@@ -467,28 +796,33 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "init",
|
||||
category: "Commands",
|
||||
usage: "init {flags}",
|
||||
about: "Generate a local agent-manager.yaml for the current directory, based on the detected stack.",
|
||||
search_terms: &["project", "setup", "scaffold"],
|
||||
about: "Generate a local agent-manager.yaml — stack detection or an embedded template.",
|
||||
search_terms: &["project", "setup", "scaffold", "template"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--force", value: "", desc: "Overwrite an existing agent-manager.yaml" },
|
||||
HelpFlag { short: "", long: "--template", value: "NAME", desc: "web | python | rust | cli ('list' shows them)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Generate a project config.", code: "init" },
|
||||
HelpExample { desc: "Regenerate over an existing one.", code: "init --force" },
|
||||
HelpExample { desc: "Generate a project config (stack detection).", code: "init" },
|
||||
HelpExample { desc: "Generate a web-stack config.", code: "init --template web" },
|
||||
HelpExample { desc: "List the available templates.", code: "init --template list" },
|
||||
],
|
||||
},
|
||||
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: &[
|
||||
@@ -499,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 {
|
||||
@@ -521,22 +858,120 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "update",
|
||||
name: "setup",
|
||||
category: "Commands",
|
||||
usage: "update {flags} [agent]",
|
||||
about: "Update an installed agent to the latest available version.",
|
||||
search_terms: &["upgrade"],
|
||||
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: "", long: "--all", value: "", desc: "Update every installed managed agent" },
|
||||
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: "agent", typ: "string", desc: "Agent name or alias (required unless --all)" },
|
||||
HelpParam { name: "demande", typ: "string", desc: "La demande en langage naturel, ex: «installe claude et lance-le»" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Update one agent.", code: "update claude-code" },
|
||||
HelpExample { desc: "Update every managed agent.", code: "update --all" },
|
||||
HelpExample { desc: "Installer puis lancer un agent.", code: "ask installe claude et lance-le" },
|
||||
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "ai",
|
||||
category: "Commands",
|
||||
usage: "ai {flags} <prompt...>",
|
||||
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
|
||||
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
|
||||
flags: &[
|
||||
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
|
||||
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
|
||||
HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" },
|
||||
HelpFlag { short: "", long: "--role", value: "ROLE", desc: "Rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator — générés par am setup)" },
|
||||
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" },
|
||||
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "prompt", typ: "string", desc: "La demande en langage naturel, ex: «liste les fichiers JSON du dossier courant»" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Mode conversationnel (défaut).", code: "ai liste tous les fichiers JSON et extrait les clés uniques" },
|
||||
HelpExample { desc: "Générer la commande sans l'exécuter (dry-run par défaut).", code: "ai --exec \"compresse les fichiers JSON en un zip\"" },
|
||||
HelpExample { desc: "Exécuter après confirmation explicite.", code: "ai --exec \"supprime les fichiers .tmp\" --yes" },
|
||||
HelpExample { desc: "Passer un dossier en contexte.", code: "ai --files ./data \"résume les données de ces fichiers\"" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "registry",
|
||||
category: "Commands",
|
||||
usage: "registry <publish|search|install|list>",
|
||||
about: "Registre communautaire (issue #77) : publier un catalogue + manifeste (source, version, auteur, sha256) sur Gitea, chercher des agents dans les sources enregistrées, et installer un catalogue avec validation du checksum et décision de confiance explicite.",
|
||||
search_terms: &["community", "publish", "manifest", "sha256", "confiance"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--source", value: "URL", desc: "URL publique du catalogue publié (publish)" },
|
||||
HelpFlag { short: "", long: "--author", value: "NOM", desc: "Auteur enregistré dans le manifeste (publish)" },
|
||||
HelpFlag { short: "", long: "--version", value: "V", desc: "Version du catalogue, défaut 1.0.0 (publish)" },
|
||||
HelpFlag { short: "", long: "--yes", value: "", desc: "Accepter la source sans confirmation (install)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("registry publish", "prépare le manifeste (sha256) à côté du catalogue"),
|
||||
("registry install", "installe avec validation checksum + confiance"),
|
||||
],
|
||||
parameters: &[
|
||||
HelpParam { name: "mot", typ: "string", desc: "Mot-clé de recherche (nom, description ou tag d'agent)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Préparer un catalogue pour publication.", code: "registry publish am-catalog.yaml --source https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --author bruno" },
|
||||
HelpExample { desc: "Chercher un agent dans les sources enregistrées.", code: "registry search claude" },
|
||||
HelpExample { desc: "Installer un catalogue en faisant confiance à la source.", code: "registry install https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "serve",
|
||||
category: "Commands",
|
||||
usage: "serve --token <TOKEN> [--host HOST] [--port PORT] [--rate-limit N]",
|
||||
about: "API HTTP + WebSocket authentifiée pour piloter am à distance (issue #80) : token obligatoire sur chaque requête, contrats --json réutilisés (stats, sessions, ps, run, start, stop, ask), WebSocket diffusant les événements du journal en temps réel, rate limiting par IP. TLS : placez le serveur derrière un reverse proxy (caddy/nginx).",
|
||||
search_terms: &["api", "http", "websocket", "token", "remote", "serveur"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--token", value: "TOKEN", desc: "Bearer token requis sur chaque requête (obligatoire)" },
|
||||
HelpFlag { short: "", long: "--host", value: "HOST", desc: "Adresse d'écoute (défaut 127.0.0.1)" },
|
||||
HelpFlag { short: "", long: "--port", value: "PORT", desc: "Port d'écoute (défaut 8080)" },
|
||||
HelpFlag { short: "", long: "--rate-limit", value: "N", desc: "Budget de requêtes par minute et par IP (défaut 120)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("/api/health", "GET — {ok, version}"),
|
||||
("/api/stats", "GET — am stats --json"),
|
||||
("/api/run", "POST {agent, model?, provider?, args?} — am run --json"),
|
||||
("/ws", "GET — WebSocket : événements du journal en temps réel"),
|
||||
],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Démarrer le serveur.", code: "serve --token MonTokenSecret --port 8080" },
|
||||
HelpExample { desc: "Appeler une route (depuis une autre machine).", code: "curl -H \"Authorization: Bearer MonTokenSecret\" http://serveur:8080/api/stats" },
|
||||
HelpExample { desc: "Lancer un agent à distance.", code: "curl -X POST -H \"Authorization: Bearer MonTokenSecret\" -d '{\"agent\":\"claude-code\"}' http://serveur:8080/api/run" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -544,7 +979,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
category: "Commands",
|
||||
usage: "start {flags} <agent>",
|
||||
about: "Start an agent (foreground by default, or detached with --background). --profile applies an environment profile (env + args).",
|
||||
search_terms: &["launch", "begin"],
|
||||
search_terms: &["launch", "begin", "group", "parallel", "healthcheck"],
|
||||
flags: START_FLAGS,
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
@@ -554,7 +989,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
examples: &[
|
||||
HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" },
|
||||
HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" },
|
||||
HelpExample { desc: "Start every agent of a group.", code: "start group:dev --background" },
|
||||
HelpExample { desc: "Start every agent of a group, waiting each health check.", code: "start group:dev --background" },
|
||||
HelpExample { desc: "Start a group without waiting.", code: "start group:dev --parallel" },
|
||||
HelpExample { desc: "Start with an environment profile.", code: "start claude-code --profile prod" },
|
||||
],
|
||||
},
|
||||
@@ -608,8 +1044,13 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
category: "Commands",
|
||||
usage: "run {flags} <agent> ...(args)",
|
||||
about: "Run the agent command directly with the given arguments (no process management).",
|
||||
search_terms: &["exec", "pass-through"],
|
||||
flags: &[],
|
||||
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
|
||||
flags: &[
|
||||
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: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
|
||||
@@ -618,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" },
|
||||
],
|
||||
},
|
||||
@@ -625,17 +1067,58 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "doctor",
|
||||
category: "Commands",
|
||||
usage: "doctor {flags}",
|
||||
about: "Check the environment: tools, config validity, paths, permissions.",
|
||||
search_terms: &["check", "environment", "repair"],
|
||||
about: "Check the environment; --watch re-runs periodically and alerts on failure.",
|
||||
search_terms: &["health", "watch", "check", "environment", "repair"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--fix", value: "", desc: "Attempt to repair problems (create directories, fix the state file)" },
|
||||
HelpFlag { short: "", long: "--fix", value: "", desc: "Repair what can be repaired automatically" },
|
||||
HelpFlag { short: "", long: "--watch", value: "SECONDS", desc: "Re-run the checks every N seconds; alert + event on failure, recover silently" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Check the environment.", code: "doctor" },
|
||||
HelpExample { desc: "Check and repair.", code: "doctor --fix" },
|
||||
HelpExample { desc: "One-shot environment report.", code: "doctor" },
|
||||
HelpExample { desc: "Watch every 30s, alerting on failure.", code: "doctor --watch 30" },
|
||||
HelpExample { desc: "Repair what can be fixed.", code: "doctor --fix" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "service",
|
||||
category: "Commands",
|
||||
usage: "service install|uninstall|status <agent> {flags}",
|
||||
about: "Register an agent as a system service (systemd / launchd / Task Scheduler).",
|
||||
search_terms: &["systemd", "autostart", "launchd", "task scheduler", "daemon"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--autostart", value: "", desc: "Start the service at boot/logon" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "agent", typ: "string", desc: "Agent name" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Install with autostart.", code: "service install claude-code --autostart" },
|
||||
HelpExample { desc: "Remove the service.", code: "service uninstall claude-code" },
|
||||
HelpExample { desc: "Check the service.", code: "service status claude-code" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "schedule",
|
||||
category: "Commands",
|
||||
usage: "schedule add|list|remove <cmd...> {flags}",
|
||||
about: "Plan am commands (daily) and run them with health reporting.",
|
||||
search_terms: &["cron", "daily", "task scheduler", "scheduled", "planning"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--at", value: "HH:MM", desc: "Time of day (default 08:00)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Daily update of every agent at 06:30.", code: "schedule add update --all --at 06:30" },
|
||||
HelpExample { desc: "List the schedules.", code: "schedule list" },
|
||||
HelpExample { desc: "Remove a schedule.", code: "schedule remove <id>" },
|
||||
HelpExample { desc: "Run a schedule now (used by the OS task).", code: "schedule run <id>" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -885,8 +1368,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "man",
|
||||
category: "Commands",
|
||||
usage: "man {flags} [command]",
|
||||
about: "Generate roff man pages from the command definitions: one page for the whole CLI, or one page per command with --output.",
|
||||
search_terms: &["documentation", "roff", "unix"],
|
||||
about: "Readable man pages in the terminal (the raw roff source is kept when piped): one page for the whole CLI, or one per command with --output.",
|
||||
search_terms: &["documentation", "roff", "unix", "manual"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--output", value: "DIR", desc: "Write am.1 plus one am-<command>.1 per command into this directory" },
|
||||
],
|
||||
@@ -896,9 +1379,10 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Print the man page of the whole CLI.", code: "man" },
|
||||
HelpExample { desc: "Print one command page.", code: "man install" },
|
||||
HelpExample { desc: "Read the man page of the whole CLI (rendered in the terminal).", code: "man" },
|
||||
HelpExample { desc: "Read one command page.", code: "man install" },
|
||||
HelpExample { desc: "Write every page into man/ (for a package).", code: "man --output man/" },
|
||||
HelpExample { desc: "Pipe the raw roff source into a real man viewer.", code: "man | man -l -" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -953,6 +1437,26 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Switch to the ocean theme (kept for the next start).", code: "theme ocean" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "tip",
|
||||
category: "Commands",
|
||||
usage: "tip {flags}",
|
||||
about: "Cheat sheet : les commandes les plus utiles avec leurs options clés, vos commandes les plus utilisées et une astuce du jour.",
|
||||
search_terms: &["cheat", "sheet", "tips", "astuce", "aide", "raccourci", "usage"],
|
||||
flags: &[HelpFlag {
|
||||
short: "",
|
||||
long: "--random",
|
||||
value: "",
|
||||
desc: "Show a single random tip instead of the whole page",
|
||||
}],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Show the whole cheat sheet.", code: "tip" },
|
||||
HelpExample { desc: "Show a single random tip.", code: "tip --random" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "ls",
|
||||
category: "Shell",
|
||||
@@ -1807,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('.')
|
||||
|
||||
@@ -198,6 +198,19 @@ pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the entries of one session file (oldest first). Missing or corrupt
|
||||
/// files yield an empty list — an export never fails on history.
|
||||
pub fn read_session(app: &App, session: &str) -> Vec<HistoryEntry> {
|
||||
let path = dir(app).join(format!("{session}.jsonl"));
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse the session id from a history file name ("20260815_143926.jsonl").
|
||||
pub fn session_of_file(path: &Path) -> Option<String> {
|
||||
path.file_name()
|
||||
|
||||
+619
@@ -0,0 +1,619 @@
|
||||
//! Internationalization (issue #74): locale selection and message lookup.
|
||||
//!
|
||||
//! The catalog uses the gettext convention: the **French message is the key**.
|
||||
//! `tr()` returns its argument unchanged in French mode (zero behavior change
|
||||
//! for the default locale), and the English translation when one exists in
|
||||
//! the catalog. A message without an entry falls back to the French text, so
|
||||
//! the CLI never shows raw keys.
|
||||
//!
|
||||
//! Locale resolution order (first match wins):
|
||||
//! 1. the `--lang` global flag (`am --lang en <cmd>`),
|
||||
//! 2. the `AM_LANG` environment variable,
|
||||
//! 3. the `LANG` / `LC_ALL` / `LC_MESSAGES` environment variables,
|
||||
//! 4. the default: French.
|
||||
//!
|
||||
//! Locale values are parsed leniently (`en`, `EN`, `en-US`, `en_US` … all
|
||||
//! select English); anything unrecognized falls back to the default. Adding
|
||||
//! another language only requires a new `Lang` variant and one more column in
|
||||
//! the catalog entries.
|
||||
//!
|
||||
//! The `--json` contracts are untouched: only display strings go through the
|
||||
//! catalog, never serialized field names or event kinds.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
/// The two supported output languages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Lang {
|
||||
Fr,
|
||||
En,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Lang::Fr => "fr",
|
||||
Lang::En => "en",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_en(self) -> bool {
|
||||
matches!(self, Lang::En)
|
||||
}
|
||||
|
||||
/// Human-readable label, in the language itself.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Lang::Fr => "français",
|
||||
Lang::En => "english",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValueEnum so clap accepts `--lang fr|en` and rejects unknown values.
|
||||
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LangArg {
|
||||
Fr,
|
||||
En,
|
||||
}
|
||||
|
||||
impl From<LangArg> for Lang {
|
||||
fn from(v: LangArg) -> Self {
|
||||
match v {
|
||||
LangArg::Fr => Lang::Fr,
|
||||
LangArg::En => Lang::En,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Active locale, as an atomic (the REPL can switch it at runtime).
|
||||
static ACTIVE: AtomicU8 = AtomicU8::new(0); // 0 = Fr, 1 = En
|
||||
|
||||
/// Current locale.
|
||||
pub fn active() -> Lang {
|
||||
match ACTIVE.load(Ordering::Relaxed) {
|
||||
1 => Lang::En,
|
||||
_ => Lang::Fr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active locale (startup from `--lang`/env, or the REPL `lang`
|
||||
/// command).
|
||||
pub fn set_lang(lang: Lang) {
|
||||
ACTIVE.store(if lang.is_en() { 1 } else { 0 }, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Resolve the locale: explicit flag, then `AM_LANG`, then `LANG`/`LC_ALL`/
|
||||
/// `LC_MESSAGES`, then the French default.
|
||||
pub fn resolve(flag: Option<LangArg>) -> Lang {
|
||||
if let Some(l) = flag {
|
||||
return l.into();
|
||||
}
|
||||
if let Ok(v) = std::env::var("AM_LANG") {
|
||||
if let Some(l) = parse(&v) {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
if let Some(l) = parse(&v) {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
}
|
||||
Lang::Fr
|
||||
}
|
||||
|
||||
/// Lenient parsing: "en", "EN", "en-US", "en_US.UTF-8", "fr-FR" …
|
||||
fn parse(value: &str) -> Option<Lang> {
|
||||
let v = value.trim().to_ascii_lowercase();
|
||||
if v.starts_with("en") {
|
||||
return Some(Lang::En);
|
||||
}
|
||||
if v.starts_with("fr") {
|
||||
return Some(Lang::Fr);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// One entry per message: `(French message, English translation)`. The French
|
||||
// text is the stable key (gettext convention): it is fixed by the call site
|
||||
// and doubles as the fallback. Placeholders (`{}`, `{name}`) must appear in
|
||||
// the same order in both languages — a test enforces it.
|
||||
|
||||
pub const CATALOG: &[(&str, &str)] = &[
|
||||
// ---- messages généraux -------------------------------------------------
|
||||
("aucun agent installé ou en cours d'exécution", "no installed or running agents"),
|
||||
("aucun agent installé", "no installed agents"),
|
||||
("aucun résultat", "no results"),
|
||||
("aucun", "none"),
|
||||
("inconnu", "unknown"),
|
||||
("disponible", "available"),
|
||||
("activé", "enabled"),
|
||||
("désactivé", "disabled"),
|
||||
("oui", "yes"),
|
||||
("non", "no"),
|
||||
("terminé", "finished"),
|
||||
("échec", "failed"),
|
||||
("interrompu", "interrupted"),
|
||||
("en cours", "running"),
|
||||
("installé", "installed"),
|
||||
("non installé", "not installed"),
|
||||
("externe", "external"),
|
||||
("non installable", "not installable"),
|
||||
("erreur", "error"),
|
||||
("avertissement", "warning"),
|
||||
("information", "info"),
|
||||
("confirmer", "confirm"),
|
||||
("annuler", "cancel"),
|
||||
("version", "version"),
|
||||
("chemin", "path"),
|
||||
("projet", "project"),
|
||||
("durée", "duration"),
|
||||
("statut", "status"),
|
||||
("session", "session"),
|
||||
("date", "date"),
|
||||
("début", "start"),
|
||||
("fin", "end"),
|
||||
("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"),
|
||||
("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"),
|
||||
("aucune détectée", "none detected"),
|
||||
("aucune commande enregistrée", "no recorded commands"),
|
||||
("lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici", "start the am shell and type a few commands: your habits will appear here"),
|
||||
("am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'", "am help <command> for details · am tip --random for a single tip · inside the am shell, just type 'tip'"),
|
||||
("Le saviez-vous ?", "Did you know?"),
|
||||
("Vos commandes les plus utilisées", "Your most used commands"),
|
||||
("Lancer & piloter", "Run & control"),
|
||||
("Installer & maintenir", "Install & maintain"),
|
||||
("Observer", "Observe"),
|
||||
("Personnaliser", "Customize"),
|
||||
("Boîte à outils", "Toolbox"),
|
||||
("démarre un agent (premier plan par défaut)", "starts an agent (foreground by default)"),
|
||||
("arrête un agent en arrière-plan (SIGTERM puis SIGKILL)", "stops a background agent (SIGTERM then SIGKILL)"),
|
||||
("redémarre avec les mêmes options (agent, groupe, profil…)", "restarts with the same options (agent, group, profile…)"),
|
||||
("supervise l'agent et le relance s'il meurt", "supervises the agent and restarts it if it dies"),
|
||||
("exécute la commande de l'agent directement (sans gestion de processus)", "runs the agent command directly (no process management)"),
|
||||
("agents installés (managed + external détectés sur le PATH)", "installed agents (managed + external detected on PATH)"),
|
||||
("cherche dans le catalogue (nom, description, catégorie, tags)", "searches the catalog (name, description, category, tags)"),
|
||||
("recommande un agent pour une demande en langage naturel (tags + usage réel)", "recommends an agent for a natural-language request (tags + real usage)"),
|
||||
("inventaire des modèles locaux (ollama, llama.cpp, LM Studio)", "inventory of local models (ollama, llama.cpp, LM Studio)"),
|
||||
("rafraîchit le catalogue officiel (diff + confirmation)", "refreshes the official catalog (diff + confirmation)"),
|
||||
("qui a modifié quoi, quand (checksums config + journal d'événements)", "who changed what, when (config checksums + event journal)"),
|
||||
("agent en service système (systemd / launchd / tâche Windows) avec démarrage auto", "registers the agent as a system service (systemd / launchd / Windows task) with autostart"),
|
||||
("planifie une commande am quotidienne (ex: update --all le matin)", "schedules a daily am command (e.g. update --all in the morning)"),
|
||||
("vérifications de l'environnement en continu, alerte dès qu'un problème apparaît", "continuous environment checks, alerts as soon as a problem appears"),
|
||||
("démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)", "starts a whole group (ordered + health wait by default, --parallel without waiting)"),
|
||||
("annule la dernière mise à jour (backup automatique avant chaque update)", "rolls back the last update (automatic backup before every update)"),
|
||||
("génère une config projet pré-remplie (web, python, rust, cli)", "generates a pre-filled project config (web, python, rust, cli)"),
|
||||
("exporte une session : métadonnées + commandes + extrait de log (JSON)", "exports a session: metadata + commands + log excerpt (JSON)"),
|
||||
("installe l'agent et ses dépendances (Node, Python, Rust…)", "installs the agent and its dependencies (Node, Python, Rust…)"),
|
||||
("met à jour un agent vers la dernière version disponible", "updates an agent to the latest available version"),
|
||||
("supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)", "removes the files and the state entry (completion refreshes)"),
|
||||
("état de tous les agents (running / installed / external)", "state of every agent (running / installed / external)"),
|
||||
("lit la fin du journal de l'agent", "reads the end of the agent log"),
|
||||
("TUI en direct : activité, stats, sessions, projets", "live TUI: activity, stats, sessions, projects"),
|
||||
("historique des sessions · statistiques · agents les plus utilisés", "session history · statistics · most used agents"),
|
||||
("TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils", "real-time TUI of managed processes (CPU, memory, uptime) with threshold alerts"),
|
||||
("pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", "pushes the state (state.json, journal, history) into the configured git repo (sync_repo)"),
|
||||
("export/import d'un bundle de transfert machine A → B (config + état + historique + backups)", "export/import of a machine-transfer bundle A → B (config + state + history + backups)"),
|
||||
("exporte une plage d'historique en playbook YAML (état_dir/playbooks/)", "exports a history range as a YAML playbook (state_dir/playbooks/)"),
|
||||
("benchmark : même tâche sur plusieurs agents (durée, exit, coût)", "benchmark: the same task on several agents (duration, exit, cost)"),
|
||||
("scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)", "event extension scripts (JSON contract stdin/stdout, timeout)"),
|
||||
("une vue chronologique de toute l'activité", "a chronological view of all activity"),
|
||||
("crée un raccourci (ex. cc → claude-code)", "creates a shortcut (e.g. cc → claude-code)"),
|
||||
("profils d'environnement réutilisables (env + args)", "reusable environment profiles (env + args)"),
|
||||
("configuration effective (catalogue fusionné, alias, groupes…)", "effective configuration (merged catalog, aliases, groups…)"),
|
||||
("change la palette du shell, des tableaux et de cette page", "changes the palette of the shell, the tables and this page"),
|
||||
("stocke un secret dans le keyring de l'OS, jamais en clair", "stores a secret in the OS keyring, never in plaintext"),
|
||||
("annotez vos agents : étoile, note libre, tags personnels", "annotate your agents: star, free note, personal tags"),
|
||||
("diagnostique l'environnement et répare les problèmes courants", "diagnoses the environment and fixes common problems"),
|
||||
("sauvegarde config + état, puis restauration", "backs up config + state, then restores"),
|
||||
("complétion dynamique : agents installés, alias et groupes au Tab", "dynamic completion: installed agents, aliases and groups on Tab"),
|
||||
("page de manuel pour une commande", "manual page for a command"),
|
||||
("cherche une commande dans toute l'aide", "searches every help page for a command"),
|
||||
("met à jour agent-manager lui-même", "updates agent-manager itself"),
|
||||
("détaché en arrière-plan, sortie dans le log", "detached in the background, output goes to the log"),
|
||||
("variable d'environnement (répétable)", "environment variable (repeatable)"),
|
||||
("profil d'environnement (env + args)", "environment profile (env + args)"),
|
||||
("kill immédiat, sans délai de grâce", "immediate kill, no grace period"),
|
||||
("délai de grâce avant le kill (défaut : 5 s)", "grace period before the kill (default: 5 s)"),
|
||||
("relance automatique avec backoff", "automatic restart with backoff"),
|
||||
("notification bureau à chaque relance", "desktop notification on every restart"),
|
||||
("catalogue complet, y compris non installés", "the whole catalog, including uninstalled agents"),
|
||||
("uniquement les agents actifs", "only active agents"),
|
||||
("name, version ou status", "name, version or status"),
|
||||
("filtre par catégorie", "filter by category"),
|
||||
("sortie machine-readable", "machine-readable output"),
|
||||
("purge les modèles inutilisés", "prunes unused models"),
|
||||
("simulation sans rien supprimer", "simulation, deletes nothing"),
|
||||
("seuil d'inutilisation (défaut : 30)", "unused threshold (default: 30)"),
|
||||
("ajoute un catalogue d'équipe par URL", "adds a team catalog by URL"),
|
||||
("rapport machine-readable", "machine-readable report"),
|
||||
("état du service", "service status"),
|
||||
("retire le service", "removes the service"),
|
||||
("liste les planifications", "lists the schedules"),
|
||||
("supprime une planification", "removes a schedule"),
|
||||
("répare automatiquement ce qui peut l'être", "automatically repairs what can be repaired"),
|
||||
("exécute l'agent dans son profil conteneur (docker/podman)", "runs the agent inside its container profile (docker/podman)"),
|
||||
("liste les points de restauration", "lists the restore points"),
|
||||
("restaure un point précis", "restores a specific point"),
|
||||
("liste les templates disponibles", "lists the available templates"),
|
||||
("destination (défaut ./am-session-<id>.json)", "destination (default ./am-session-<id>.json)"),
|
||||
("purge les sessions terminées au-delà de N jours", "purges sessions finished more than N days ago"),
|
||||
("choisit la méthode (index ou type:npm, binary…)", "selects the method (index or type:npm, binary…)"),
|
||||
("réinstalle même si déjà présent", "reinstalls even if already present"),
|
||||
("tous les agents installés d'un coup", "every installed agent at once"),
|
||||
("supprime aussi les logs et la définition de la config", "also removes the logs and the config definition"),
|
||||
("suit le log en direct", "follows the log live"),
|
||||
("nombre de lignes (défaut : 20)", "number of lines (default: 20)"),
|
||||
("7d, 30d, 90d ou all", "7d, 30d, 90d or all"),
|
||||
("tokens + coût estimé par agent (issue #49)", "tokens + estimated cost per agent (issue #49)"),
|
||||
("rafraîchissement (défaut 2s)", "refresh interval (default 2s)"),
|
||||
("flux JSON pour les scripts", "JSON stream for scripts"),
|
||||
("message de commit personnalisé", "custom commit message"),
|
||||
("pousse automatiquement à la fermeture du REPL", "pushes automatically when the REPL closes"),
|
||||
("crée agent-manager-migrate.amx", "creates agent-manager-migrate.amx"),
|
||||
("chemin du bundle", "bundle path"),
|
||||
("fichier playbook", "playbook file"),
|
||||
("rejoue pas à pas avec confirmation", "replays step by step with confirmation"),
|
||||
("exécution concurrente", "concurrent execution"),
|
||||
("rapport comparatif stable (scripts/CI)", "stable comparison report (scripts/CI)"),
|
||||
("exécute un plugin sur un événement de test (CI)", "runs a plugin against a test event (CI)"),
|
||||
("activité à partir d'une date", "activity from a given date"),
|
||||
("fichier de destination (défaut : am-export.json)", "destination file (default: am-export.json)"),
|
||||
("vérifie seulement si une version existe", "only checks whether a version exists"),
|
||||
("exécution séquentielle", "sequential execution"),
|
||||
("kill de l'arbre de processus au timeout", "kills the process tree on timeout"),
|
||||
("Tab complète vos agents", "Tab completes your agents"),
|
||||
("dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.", "in the am shell, type 'start ' then Tab: installed agents, aliases and groups show up — and the list refreshes after every install/uninstall."),
|
||||
("!! relance la dernière commande", "!! re-runs the last command"),
|
||||
("l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.", "the shell history accepts !!, !N and !prefix to re-run a command without retyping it."),
|
||||
("Le shell am double le shell système", "The am shell doubles as the system shell"),
|
||||
("tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.", "anything that is not an am command is run by your shell (pwsh, cmd, bash…): no need to leave to launch a tool."),
|
||||
("--dry-run simule sans rien changer", "--dry-run simulates without changing anything"),
|
||||
("testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.", "test 'install', 'update' or 'uninstall' safely: am shows what it would do, without touching the disk."),
|
||||
("Les groupes lancent plusieurs agents", "Groups launch several agents"),
|
||||
("'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.", "'start group:dev' launches the whole group at once; create your own in the config."),
|
||||
("Cherchez dans toute l'aide", "Search all the help"),
|
||||
("'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.", "'am help --find <word>' scans the descriptions, usages and examples of every command."),
|
||||
("--json pour les scripts", "--json for scripts"),
|
||||
("quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.", "almost every command accepts --json and outputs structured data, perfect for your scripts."),
|
||||
("Les alias raccourcissent tout", "Aliases shorten everything"),
|
||||
("'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.", "'alias add cc claude-code' then 'start cc': a short name for every agent."),
|
||||
("export/import sauvegardent votre monde", "export/import back up your world"),
|
||||
("config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.", "config, state, annotations and profiles go into a JSON file, restorable with 'import'."),
|
||||
("Les secrets vivent dans le keyring", "Secrets live in the keyring"),
|
||||
("'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.", "'secret set API_KEY --agent pi' stores the value in the OS keychain, never in plaintext in the config."),
|
||||
("Le thème suit votre humeur", "The theme follows your mood"),
|
||||
("'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.", "'theme ocean', 'theme dracula'… the palette of the am shell, the tables and this page changes instantly."),
|
||||
("doctor répare", "doctor repairs"),
|
||||
("'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).", "'am doctor --fix' checks the environment and fixes common problems (missing folders, corrupted state…)."),
|
||||
// ---- web (issue #54) ---------------------------------------------------
|
||||
("am web — {} (Ctrl-C pour arrêter)", "am web — {} (Ctrl-C to stop)"),
|
||||
("ouverture du navigateur…", "opening the browser…"),
|
||||
("Vue d'ensemble", "Overview"),
|
||||
("Statistiques", "Statistics"),
|
||||
("Événements", "Events"),
|
||||
("Projets", "Projects"),
|
||||
("Agents installés", "Installed agents"),
|
||||
("En cours", "Running"),
|
||||
("Sessions (7 j)", "Sessions (7d)"),
|
||||
("Événements (24 h)", "Events (24h)"),
|
||||
("Top agents — sessions (7 j)", "Top agents — sessions (7d)"),
|
||||
("Activité — sessions par jour (14 j)", "Activity — sessions per day (14d)"),
|
||||
("Activité récente", "Recent activity"),
|
||||
("Sessions en cours", "Running sessions"),
|
||||
("Statistiques par agent — période 30 j", "Per-agent statistics — last 30 days"),
|
||||
("Session", "Session"),
|
||||
("Agent", "Agent"),
|
||||
("Projet", "Project"),
|
||||
("Statut", "Status"),
|
||||
("Durée", "Duration"),
|
||||
("Heure", "Time"),
|
||||
("Type", "Type"),
|
||||
("Détail", "Details"),
|
||||
("Dernière activité", "Last activity"),
|
||||
("Journal d'événements — aujourd'hui", "Event journal — today"),
|
||||
("Dernière mise à jour", "Last updated"),
|
||||
("Filtrer par agent, projet ou id…", "Filter by agent, project or id…"),
|
||||
("Aucune session ne correspond au filtre", "No sessions match the filter"),
|
||||
("Aucune session en cours", "No running sessions"),
|
||||
("Aucun événement", "No events"),
|
||||
("Aucune activité enregistrée", "No activity recorded yet"),
|
||||
("Aucun projet suivi", "No tracked projects"),
|
||||
("Actualiser", "Refresh"),
|
||||
("serveur local", "local server"),
|
||||
("Temps total", "Total time"),
|
||||
("Projets suivis", "Tracked projects"),
|
||||
("Terminée", "Finished"),
|
||||
("Échec", "Failed"),
|
||||
("Interrompue", "Interrupted"),
|
||||
("chargement…", "loading…"),
|
||||
("erreur de chargement", "load error"),
|
||||
("Lancements", "Launches"),
|
||||
("Durée moyenne", "Avg duration"),
|
||||
("Échecs", "Failures"),
|
||||
("Démarrée", "Started"),
|
||||
("Fin", "End"),
|
||||
];
|
||||
|
||||
/// Look up the English translation of a French message. Returns None when the
|
||||
/// message is not in the catalog (the caller falls back to the French text).
|
||||
pub fn lookup_en(fr: &str) -> Option<&'static str> {
|
||||
CATALOG
|
||||
.iter()
|
||||
.find(|(k, _)| *k == fr)
|
||||
.map(|(_, en)| *en)
|
||||
}
|
||||
|
||||
/// Translate a message for the active locale. In French mode the input is
|
||||
/// returned unchanged; in English mode the catalog translation is used, with
|
||||
/// the French text as fallback when the message is not translated yet.
|
||||
pub fn tr<'a>(key: &'a str) -> &'a str {
|
||||
tr_in(active(), key)
|
||||
}
|
||||
|
||||
/// Translate a message for an explicit locale.
|
||||
pub fn tr_in<'a>(lang: Lang, key: &'a str) -> &'a str {
|
||||
match lang {
|
||||
Lang::Fr => key,
|
||||
Lang::En => lookup_en(key).unwrap_or(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a localized message: `tr_fmt!("agent '{}' installé", name)`.
|
||||
/// The English translation keeps the same placeholders (enforced by test).
|
||||
/// Edition 2021 forbids non-literal format strings, so the formatting is
|
||||
/// done by a small runtime `{}`-replacer; catalog entries therefore use
|
||||
/// positional `{}` placeholders only (no `{name}`, `{:.1}`, …).
|
||||
#[macro_export]
|
||||
macro_rules! tr_fmt {
|
||||
($key:literal $(, $arg:expr)*) => {
|
||||
$crate::i18n::tr_fmt($key, &[$(&$arg as &dyn ::std::fmt::Display),*])
|
||||
};
|
||||
}
|
||||
|
||||
/// Format a message for the active locale, replacing every `{}` with the
|
||||
/// Display rendering of the matching argument.
|
||||
pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
match active() {
|
||||
Lang::Fr => fmt_args(key, args),
|
||||
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let mut rest = template;
|
||||
let mut i = 0;
|
||||
while let Some(pos) = rest.find("{}") {
|
||||
out.push_str(&rest[..pos]);
|
||||
if let Some(arg) = args.get(i) {
|
||||
out.push_str(&arg.to_string());
|
||||
}
|
||||
i += 1;
|
||||
rest = &rest[pos + 2..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Env-var tests mutate the process environment and would race when run
|
||||
/// in parallel; serialize them.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn placeholders(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let after = &rest[i..];
|
||||
let close = after.find('}').map(|j| j + 1).unwrap_or(after.len());
|
||||
out.push(after[..close].to_string());
|
||||
rest = &after[close..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_locale_values() {
|
||||
assert_eq!(parse("en"), Some(Lang::En));
|
||||
assert_eq!(parse("EN"), Some(Lang::En));
|
||||
assert_eq!(parse("en-US"), Some(Lang::En));
|
||||
assert_eq!(parse("en_US.UTF-8"), Some(Lang::En));
|
||||
assert_eq!(parse("fr"), Some(Lang::Fr));
|
||||
assert_eq!(parse("fr-FR"), Some(Lang::Fr));
|
||||
assert_eq!(parse("de"), None);
|
||||
assert_eq!(parse(""), None);
|
||||
assert_eq!(parse(" en "), Some(Lang::En));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_wins_over_env() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("AM_LANG", "en");
|
||||
std::env::set_var("LANG", "fr_FR");
|
||||
let flag = Some(LangArg::En);
|
||||
assert_eq!(resolve(flag), Lang::En);
|
||||
let flag = Some(LangArg::Fr);
|
||||
assert_eq!(resolve(flag), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn am_lang_env_beats_lang_env() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("AM_LANG", "en");
|
||||
std::env::set_var("LANG", "fr_FR");
|
||||
assert_eq!(resolve(None), Lang::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_env_falls_back_to_default() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("AM_LANG");
|
||||
std::env::set_var("LANG", "en_GB");
|
||||
assert_eq!(resolve(None), Lang::En);
|
||||
std::env::set_var("LANG", "fr_FR");
|
||||
assert_eq!(resolve(None), Lang::Fr);
|
||||
std::env::remove_var("LANG");
|
||||
std::env::remove_var("LC_ALL");
|
||||
std::env::remove_var("LC_MESSAGES");
|
||||
assert_eq!(resolve(None), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_env_falls_back_to_default() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("AM_LANG");
|
||||
std::env::set_var("LANG", "de_DE");
|
||||
assert_eq!(resolve(None), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tr_returns_input_in_french() {
|
||||
set_lang(Lang::Fr);
|
||||
assert_eq!(tr("aucun agent installé"), "aucun agent installé");
|
||||
assert_eq!(tr("message non catalogué"), "message non catalogué");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tr_translates_in_english_and_falls_back() {
|
||||
set_lang(Lang::En);
|
||||
assert_eq!(tr("aucun agent installé"), "no installed agents");
|
||||
// Not in the catalog: the French text is the fallback, never a key.
|
||||
assert_eq!(tr("message non catalogué"), "message non catalogué");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tr_fmt_formats_both_locales() {
|
||||
set_lang(Lang::Fr);
|
||||
assert_eq!(crate::tr_fmt!("agent '{}' installé", "cc"), "agent 'cc' installé");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_args_replaces_positional_placeholders() {
|
||||
assert_eq!(fmt_args("a {} b", &[&"x", &"y"]), "a x b");
|
||||
assert_eq!(fmt_args("{}", &[&42]), "42");
|
||||
assert_eq!(fmt_args("no placeholders", &[]), "no placeholders");
|
||||
assert_eq!(fmt_args("{} {}", &[&1]), "1 ");
|
||||
assert_eq!(fmt_args("", &[&1]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_is_consistent() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (fr, en) in CATALOG {
|
||||
assert!(!fr.is_empty(), "empty french key");
|
||||
assert!(!en.is_empty(), "empty english translation for {fr:?}");
|
||||
assert!(seen.insert(*fr), "duplicate catalog key: {fr:?}");
|
||||
// Placeholders must line up between the two languages.
|
||||
assert_eq!(
|
||||
placeholders(fr),
|
||||
placeholders(en),
|
||||
"placeholder mismatch for {fr:?}"
|
||||
);
|
||||
// Catalog entries only use positional {} placeholders: the
|
||||
// runtime formatter (tr_fmt) cannot honour {name} or {:.1}.
|
||||
assert!(
|
||||
!fr.contains('{') || placeholders(fr).iter().all(|p| p == "{}"),
|
||||
"{fr:?}: only '{{}}' placeholders are allowed in the catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_placeholder_in_fr_is_present_in_en() {
|
||||
for (fr, en) in CATALOG {
|
||||
for ph in placeholders(fr) {
|
||||
assert!(
|
||||
en.contains(&ph),
|
||||
"{fr:?}: placeholder {ph} missing in {en:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user