Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a3eebbd6 | ||
|
|
e71cffb3c0 | ||
|
|
7fb65c257d | ||
|
|
068940495a | ||
|
|
d71c3dd34d | ||
|
|
50f515e406 | ||
|
|
313645eea2 | ||
|
|
02517735be | ||
|
|
0f81b7c585 | ||
|
|
0a12c617b2 | ||
|
|
ee490f5eaf | ||
|
|
79e032b563 | ||
|
|
d0f52eb7c0 | ||
|
|
38d38d51e3 | ||
|
|
5bcbc1c81f | ||
|
|
38297c2bf3 | ||
|
|
ac6f2ffaa5 | ||
|
|
2d90c48532 | ||
|
|
92af59a71f | ||
|
|
900f70c249 | ||
|
|
12eb09b01a | ||
|
|
7cab1a0b5c | ||
|
|
6d51ce9504 | ||
|
|
68eb170f57 | ||
|
|
ebc753cc4a | ||
|
|
7312e6ceab | ||
|
|
d38cd7352f | ||
|
|
1948e3bc79 | ||
|
|
4735fc1502 | ||
|
|
fa51cdb339 | ||
|
|
7f4d74f811 | ||
|
|
4c7866a25a | ||
|
|
aa0d8e43eb | ||
|
|
e82979e6a3 | ||
|
|
1b3fe09074 | ||
|
|
63dc36660b | ||
|
|
6a6676fc5d | ||
|
|
d9ffc34156 | ||
|
|
22bc529818 | ||
|
|
890ad953e5 | ||
|
|
98c0b30f68 | ||
|
|
53893f2967 |
@@ -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
|
||||
|
||||
+105
-6
@@ -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.2` · 🦀 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
|
||||
@@ -584,7 +632,7 @@ dist/ Binaires précompilés
|
||||
├── am-linux-x86_64
|
||||
├── am-linux-aarch64
|
||||
├── am-windows-x86_64.zip
|
||||
├── am_0.4.2_amd64.deb
|
||||
├── am_0.4.3_amd64.deb
|
||||
└── ...
|
||||
|
||||
packaging/ Scripts de packaging
|
||||
@@ -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
|
||||
@@ -685,4 +784,4 @@ flowchart LR
|
||||
|
||||
---
|
||||
|
||||
*Document généré pour agent-manager v0.4.2* 🚀
|
||||
*Document généré pour agent-manager v0.4.3* 🚀
|
||||
|
||||
Generated
+213
-11
@@ -21,9 +21,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.4.2"
|
||||
version = "1.1.3"
|
||||
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.2"
|
||||
version = "1.1.4"
|
||||
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.
|
||||
|
||||
|
||||
+5
-5
@@ -62,7 +62,7 @@ doit signaler aucune dépendance manquante.
|
||||
|
||||
Une fois toutes les archives dans `dist/` :
|
||||
|
||||
scripts/render-manifests.ps1 -Version 0.4.2
|
||||
scripts/render-manifests.ps1 -Version 0.4.3
|
||||
|
||||
Ce script rend, avec la version et les sha256 réels :
|
||||
|
||||
@@ -73,16 +73,16 @@ Ce script rend, avec la version et les sha256 réels :
|
||||
- `dist/homebrew/am.rb` → à servir via un tap, ou à soumettre
|
||||
- `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz
|
||||
dans ~/rpmbuild/SOURCES)
|
||||
- Paquet deb : `packaging/deb/make-deb.sh 0.4.2` → dist/am_0.4.2_amd64.deb
|
||||
- Paquet deb : `packaging/deb/make-deb.sh 0.4.3` → dist/am_0.4.3_amd64.deb
|
||||
|
||||
## 4. Créer la release sur Gitea
|
||||
|
||||
Via l'interface web : Releases > New Release, tag v0.4.2, attacher les
|
||||
Via l'interface web : Releases > New Release, tag v0.4.3, attacher les
|
||||
archives. Ou via l'API (jeton Gitea requis) :
|
||||
|
||||
curl -X POST -H "Authorization: token <JETON>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"v0.4.2","name":"v0.4.2","body":"notes de version"}' \
|
||||
-d '{"tag_name":"v0.4.3","name":"v0.4.3","body":"notes de version"}' \
|
||||
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases
|
||||
|
||||
curl -X POST -H "Authorization: token <JETON>" \
|
||||
@@ -95,7 +95,7 @@ archives. Ou via l'API (jeton Gitea requis) :
|
||||
|
||||
## 5. Tag git (déclenche aussi le pipeline CI)
|
||||
|
||||
git tag v0.4.2 && git push origin v0.4.2
|
||||
git tag v0.4.3 && git push origin v0.4.3
|
||||
|
||||
## Conventions de nommage (attendues par les installateurs et self-update)
|
||||
|
||||
|
||||
+265
-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,92 @@
|
||||
> profils d'environnement (#40), les complétions dynamiques + man pages
|
||||
> (#41) et le packaging officiel + CI (#42). Binaires Windows/Linux publiés
|
||||
> sur Gitea — am self-update et les installateurs servent la v0.4.1.
|
||||
>
|
||||
> ✅ **Phase 2 livrée en v0.6.0 (2026-08-19)** : 28/28 issues clôturées
|
||||
> (#47–#75), 250+ tests. am web, i18n, services, schedule, lab, sync,
|
||||
> migrate, models, plugins d'événements… Binaires publiés sur Gitea.
|
||||
>
|
||||
> ✅ **Phase v0.7.0 livrée (2026-08-19)** : 5/5 issues clôturées (#88–#92) —
|
||||
> registre de providers LLM (am providers), secrets partagés par provider,
|
||||
> flags --provider/--model/--no-config à l'install, configuration
|
||||
> post-install (bloc config:).
|
||||
>
|
||||
> ✅ **Phase 3 livrée en v1.0.0 (2026-08-20)** : 5/5 issues clôturées
|
||||
> (#76–#80) — télémétrie opt-in, am registry, am ask, profils sandbox,
|
||||
> am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les
|
||||
> archives, prompt REPL avec shell actif.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry
|
||||
> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect`
|
||||
> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python),
|
||||
> plus de faux « external » ; docs alignées.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.4 (2026-08-20)** : catalogue enrichi — 5 agents
|
||||
> **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub
|
||||
> Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI
|
||||
> (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée**
|
||||
> (issues #96 #97) — langage naturel → action shell via AIChat : mode
|
||||
> conversationnel (aichat -f <ctx>), mode --exec avec pipeline de sécurité
|
||||
> (génération aichat --dry-run, classification safe/risky + certainité,
|
||||
> politique dry-run par défaut configurable settings.shell_ai, confirmation
|
||||
> y/N pour les commandes risky, exécution via le shell de l'utilisateur),
|
||||
> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements
|
||||
> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée.
|
||||
>
|
||||
> 🔧 **Maintenance v1.0.6 (2026-08-20)** : installateur Windows corrigé —
|
||||
> `install.ps1` n'acceptait que l'entrée `am.exe` dans l'archive de release
|
||||
> (la CI et les archives manuelles utilisent `am-windows-x86_64.exe`) →
|
||||
> repli systématique sur `cargo install` au lieu du binaire précompilé.
|
||||
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
|
||||
> les deux entrées pour la compatibilité maximale. Chemin du message
|
||||
> cargo install corrigé (`.cargo\bin\am.exe`).
|
||||
>
|
||||
> 🚀 **Épique v1.1.0 (2026-08-20)** : **Onboarding, Copilot & Rôles aichat**
|
||||
> (Axe 14) — `am setup` (wizard provider + token keyring + modèle + ping),
|
||||
> installation automatique d'aichat (wizard + `am ai` + installateurs),
|
||||
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
|
||||
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
|
||||
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
|
||||
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
|
||||
> validateur : « unknown field `providers` »). Le registre vivant sous
|
||||
> `settings.providers`, le wizard écrit désormais
|
||||
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
|
||||
> base_url quand un bloc provider est écrit). Test de régression
|
||||
> `setup_persists_under_settings_providers`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.2 (2026-08-20)** :
|
||||
> - `am setup` : **openrouter** ajouté au choix des providers (base URL,
|
||||
> token via OPENROUTER_API_KEY, 6 modèles déclarés proposés) ;
|
||||
> le choix du modèle liste les modèles disponibles du provider.
|
||||
> - `am ai` sans `--role` utilise **am-copilot par défaut** (régénération
|
||||
> automatique des rôles si le fichier manque, dégradation douce).
|
||||
> - Bug `am migrate --import` : la config importée était écrite dans le
|
||||
> répertoire config GLOBAL (user_config_dir) au lieu du config actif de
|
||||
> l'app — l'import écrasait la config réelle de la machine (et la suite
|
||||
> de tests détruisait la config utilisateur à chaque `cargo test`).
|
||||
> L'import respecte désormais `app.paths.config_dir` (--config/isolated).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.3 (2026-08-21)** : fix `resolve_exec` — pour un
|
||||
> agent INSTALLÉ avec un binaire enregistré, le premier token de `run:`
|
||||
> (ex. `picoclaw`) n'était jamais consommé : `am start <agent>` lançait
|
||||
> `<bin> <token-run>` (picoclaw rejetait son propre nom comme
|
||||
> sous-commande inconnue ; aichat recevait « aichat » comme prompt
|
||||
> one-shot). Le token est désormais consommé dans la branche « binaire
|
||||
> enregistré existant » (les branches non-installé/binaire manquant
|
||||
> étaient déjà correctes). Test de régression
|
||||
> `resolve_exec_installed_agent_does_not_leak_run_token`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.4 (2026-08-21)** : hints de configuration
|
||||
> post-install — nouveau champ `setup_hints` dans la définition d'un agent
|
||||
> (config.yaml) : commandes de configuration manuelle affichées après
|
||||
> l'install quand l'agent gère sa propre config (ex. picoclaw
|
||||
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
||||
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
||||
> premier agent du catalogue à les déclarer.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +121,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 +183,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 +256,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 +275,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 +332,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 +371,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 +415,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 +621,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 +658,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).*
|
||||
|
||||
+197
@@ -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
|
||||
@@ -834,6 +1025,12 @@ agents:
|
||||
repo: sipeed/picoclaw
|
||||
binary: picoclaw
|
||||
run: picoclaw
|
||||
# PicoClaw gère sa config par sous-commandes (pas de fichier à écrire) :
|
||||
# ces commandes sont affichées après l'install avec {model} substitué.
|
||||
setup_hints:
|
||||
- "picoclaw onboard"
|
||||
- "picoclaw model {model}"
|
||||
- "picoclaw auth login"
|
||||
tags: [go, tiny]
|
||||
|
||||
- name: ironclaw
|
||||
|
||||
@@ -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.2"
|
||||
.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.2
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Renders the packaging templates (scoop, winget, homebrew, rpm) with the
|
||||
# version and the sha256 of every release archive.
|
||||
# Usage: scripts/render-manifests.ps1 -Version 0.4.2 [-DistDir dist]
|
||||
# Usage: scripts/render-manifests.ps1 -Version 0.4.3 [-DistDir dist]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version,
|
||||
[string]$DistDir = "dist"
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am web — Cockpit sombre (mockup)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
|
||||
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
|
||||
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
|
||||
}
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
|
||||
}
|
||||
.layout { display:flex; min-height:100vh; }
|
||||
/* ---------- Sidebar ---------- */
|
||||
.side {
|
||||
width:225px; flex-shrink:0; background:var(--panel2);
|
||||
border-right:1px solid var(--border); padding:18px 12px;
|
||||
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
|
||||
}
|
||||
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
|
||||
.logo-badge {
|
||||
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
|
||||
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
|
||||
}
|
||||
.logo b { font-size:16px; letter-spacing:.5px; }
|
||||
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
|
||||
.nav-item {
|
||||
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
|
||||
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
|
||||
}
|
||||
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
|
||||
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
|
||||
.nav-item .ico { width:18px; text-align:center; }
|
||||
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
|
||||
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; }
|
||||
/* ---------- Main ---------- */
|
||||
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
|
||||
.topbar h1 { font-size:20px; font-weight:700; }
|
||||
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
|
||||
.top-actions { display:flex; align-items:center; gap:10px; }
|
||||
.badge {
|
||||
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
|
||||
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
|
||||
}
|
||||
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
|
||||
.badge.live::before { content:"● "; }
|
||||
.btn {
|
||||
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
|
||||
font-weight:700; font-size:13px; cursor:pointer;
|
||||
}
|
||||
.btn:hover { filter:brightness(1.1); }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
|
||||
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
|
||||
/* ---------- KPI cards ---------- */
|
||||
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
|
||||
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
|
||||
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
|
||||
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
|
||||
.kpi .delta { font-size:11.5px; margin-top:4px; }
|
||||
.kpi .delta.up { color:var(--ok); } .kpi .delta.down { color:var(--err); }
|
||||
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
|
||||
/* ---------- Panels ---------- */
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
|
||||
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
|
||||
/* bars */
|
||||
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
|
||||
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
|
||||
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); }
|
||||
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
/* trend svg */
|
||||
.trend-wrap { position:relative; }
|
||||
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
|
||||
/* tables */
|
||||
table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
|
||||
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
|
||||
tbody tr:hover { background:rgba(61,220,151,.05); }
|
||||
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; }
|
||||
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
|
||||
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
|
||||
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
|
||||
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
|
||||
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
|
||||
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
|
||||
.search:focus { outline:none; border-color:var(--accent); }
|
||||
/* timeline */
|
||||
.tl { list-style:none; }
|
||||
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
|
||||
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
|
||||
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
|
||||
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
|
||||
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
|
||||
.panel.hidden, .view { display:none; } .view.active { display:block; }
|
||||
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="logo">
|
||||
<div class="logo-badge">am</div>
|
||||
<div><b>agent-manager</b><small>cockpit des agents</small></div>
|
||||
</div>
|
||||
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">Vue d'ensemble</span></div>
|
||||
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">Statistiques</span></div>
|
||||
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
|
||||
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">Événements</span></div>
|
||||
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">Projets</span></div>
|
||||
<div class="side-foot">
|
||||
<span><span class="dot"></span>v0.6.0 · lang FR ·<br>127.0.0.1:7878</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 id="view-title">Vue d'ensemble</h1>
|
||||
<div class="sub" id="last-update">Dernière mise à jour : il y a 5 s</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="badge live">serveur local</span>
|
||||
<span class="badge">127.0.0.1:7878</span>
|
||||
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== OVERVIEW ===== -->
|
||||
<section class="view active" id="overview">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="emoji">🤖</span><div class="label">Agents installés</div><div class="value">9</div><div class="delta up">▲ +1 cette semaine</div></div>
|
||||
<div class="kpi"><span class="emoji">⚡</span><div class="label">En cours</div><div class="value">2</div><div class="delta up">● claude-code · codex</div></div>
|
||||
<div class="kpi"><span class="emoji">⏱</span><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 % vs semaine préc.</div></div>
|
||||
<div class="kpi"><span class="emoji">📜</span><div class="label">Événements (24 h)</div><div class="value">132</div><div class="delta down">▼ 8 % vs hier</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>Top agents — sessions (7 j)</h3>
|
||||
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
|
||||
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
|
||||
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
|
||||
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
|
||||
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
|
||||
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Activité — sessions par jour (14 j)</h3>
|
||||
<div class="trend-wrap">
|
||||
<svg viewBox="0 0 480 150" width="100%" height="150">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3ddc97" stop-opacity=".35"/><stop offset="1" stop-color="#3ddc97" stop-opacity="0"/></linearGradient></defs>
|
||||
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g)"/>
|
||||
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3ddc97" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="480" cy="34" r="4" fill="#38bdf8"/>
|
||||
</svg>
|
||||
<div class="trend-axis"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>Activité récente</h3>
|
||||
<ul class="tl">
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">claude-code</span> terminé — session 3 h 12 min</span><span class="m">il y a 2 min</span></li>
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">codex</span> démarré — projet flowdeck</span><span class="m">il y a 14 min</span></li>
|
||||
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">jcode</span> mis à jour 0.76.0 → 0.78.1</span><span class="m">il y a 1 h</span></li>
|
||||
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">aider</span> échec — exit code 1</span><span class="m">il y a 3 h</span></li>
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">gemini-cli</span> terminé — session 42 min</span><span class="m">il y a 5 h</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Sessions en cours</h3>
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Projet</th><th>Durée</th><th>Démarrée</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>agent-manager</td><td class="mono">3 h 12 min</td><td class="mono">14:26</td></tr>
|
||||
<tr><td>codex</td><td>flowdeck</td><td class="mono">14 min</td><td class="mono">17:18</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="running-empty" style="display:none">Aucune session en cours</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== STATS ===== -->
|
||||
<section class="view" id="stats">
|
||||
<div class="panel">
|
||||
<h3>Statistiques par agent — période 30 j</h3>
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
|
||||
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
|
||||
<tr><td>opencode</td><td>9</td><td class="mono">4 h 40 m</td><td><span class="pill fail">55 %</span></td><td class="mono">0,90 €</td><td class="mono">il y a 2 j</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SESSIONS ===== -->
|
||||
<section class="view" id="sessions">
|
||||
<div class="panel">
|
||||
<h3>Sessions — 7 derniers jours</h3>
|
||||
<input class="search" id="sess-search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
|
||||
<tbody id="sess-body">
|
||||
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
|
||||
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
|
||||
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
|
||||
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
|
||||
<tr><td class="mono">20260817_183011_e2f3a4</td><td>jcode</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 22 min</td><td class="mono">19:35</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="sess-empty" style="display:none">Aucune session ne correspond au filtre</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== EVENTS ===== -->
|
||||
<section class="view" id="events">
|
||||
<div class="panel">
|
||||
<h3>Journal d'événements — aujourd'hui</h3>
|
||||
<ul class="tl">
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> codex — projet flowdeck</span><span class="m">17:18</span></li>
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> claude-code — session 1 h 05 min</span><span class="m">16:40</span></li>
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> claude-code — projet agent-manager</span><span class="m">15:34</span></li>
|
||||
<li><span class="dot" style="background:var(--err)"></span><span class="t"><span class="a">run</span> aider — exit code 1 (fichier manquant)</span><span class="m">14:33</span></li>
|
||||
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">update</span> jcode 0.76.0 → 0.78.1</span><span class="m">13:05</span></li>
|
||||
<li><span class="dot" style="background:var(--ok)"></span><span class="t"><span class="a">stop</span> gemini-cli — session 42 min</span><span class="m">12:12</span></li>
|
||||
<li><span class="dot" style="background:var(--accent2)"></span><span class="t"><span class="a">start</span> gemini-cli — projet imago</span><span class="m">11:30</span></li>
|
||||
<li><span class="dot" style="background:var(--purple)"></span><span class="t"><span class="a">install</span> opencode 0.14.2 — méthode binary</span><span class="m">10:02</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PROJECTS ===== -->
|
||||
<section class="view" id="projects">
|
||||
<div class="kpis" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="kpi"><div class="label">Projets suivis</div><div class="value">4</div><div class="delta up">▲ 1 nouveau cette semaine</div></div>
|
||||
<div class="kpi"><div class="label">Sessions (7 j)</div><div class="value">47</div><div class="delta up">▲ +12 %</div></div>
|
||||
<div class="kpi"><div class="label">Temps total</div><div class="value">96 h</div><div class="delta up">▲ +8 h vs semaine préc.</div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Projets</h3>
|
||||
<table>
|
||||
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
|
||||
document.querySelectorAll('.nav-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
const v = el.dataset.view;
|
||||
document.getElementById(v).classList.add('active');
|
||||
document.getElementById('view-title').textContent = titles[v];
|
||||
});
|
||||
});
|
||||
function fakeRefresh(){
|
||||
const t = new Date();
|
||||
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + t.toLocaleTimeString('fr-FR');
|
||||
}
|
||||
function filterSessions(q){
|
||||
q = q.toLowerCase();
|
||||
let shown = 0;
|
||||
document.querySelectorAll('#sess-body tr').forEach(tr => {
|
||||
const ok = tr.textContent.toLowerCase().includes(q);
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
if (ok) shown++;
|
||||
});
|
||||
document.getElementById('sess-empty').style.display = shown ? 'none' : 'block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am web — Admin clair (mockup)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#f6f7f9; --panel:#ffffff; --border:#e3e7ec; --fg:#1c2430; --muted:#64748b;
|
||||
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --err:#dc2626; --purple:#7c3aed;
|
||||
}
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
|
||||
}
|
||||
/* header bar */
|
||||
.head {
|
||||
background:var(--panel); border-bottom:1px solid var(--border);
|
||||
padding:0 28px; height:56px; display:flex; align-items:center; gap:24px; position:sticky; top:0; z-index:5;
|
||||
}
|
||||
.head .brand { font-weight:800; font-size:16px; display:flex; align-items:center; gap:9px; }
|
||||
.brand .sq { width:26px; height:26px; border-radius:7px; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; }
|
||||
.tabs { display:flex; gap:4px; height:100%; }
|
||||
.tab { display:flex; align-items:center; padding:0 16px; color:var(--muted); cursor:pointer; font-size:13.5px; font-weight:500; border-bottom:2px solid transparent; }
|
||||
.tab:hover { color:var(--fg); }
|
||||
.tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||||
.head .spacer { flex:1; }
|
||||
.hbadge { font-size:11.5px; color:var(--muted); background:var(--bg); border:1px solid var(--border); padding:4px 10px; border-radius:99px; font-family:ui-monospace,Consolas,monospace; }
|
||||
.hbadge.ok { color:var(--ok); }
|
||||
.btn { background:var(--accent); color:#fff; border:none; border-radius:7px; padding:7px 14px; font-weight:600; font-size:13px; cursor:pointer; }
|
||||
.btn:hover { background:#1d4ed8; }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
|
||||
.btn.ghost:hover { color:var(--fg); }
|
||||
.wrap { max-width:1150px; margin:26px auto; padding:0 28px; }
|
||||
.page-title { font-size:19px; font-weight:700; margin-bottom:4px; }
|
||||
.page-sub { color:var(--muted); font-size:12.5px; margin-bottom:20px; }
|
||||
.cards { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
|
||||
.card { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:15px 17px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
|
||||
.card .l { color:var(--muted); font-size:11.5px; text-transform:uppercase; letter-spacing:.5px; }
|
||||
.card .v { font-size:26px; font-weight:800; margin-top:5px; color:#111827; }
|
||||
.card .d { font-size:11.5px; margin-top:3px; } .card .d.up{color:var(--ok);} .card .d.down{color:var(--err);}
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px 18px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
|
||||
.panel h3 { font-size:12.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--muted); margin-bottom:14px; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.4px; padding:7px 10px; border-bottom:1px solid var(--border); background:var(--bg); }
|
||||
td { padding:9px 10px; border-bottom:1px solid var(--border); }
|
||||
tbody tr:hover { background:#fafbfc; }
|
||||
.pill { font-size:10.5px; padding:2px 9px; border-radius:99px; font-weight:600; }
|
||||
.pill.ok { background:#dcfce7; color:var(--ok); } .pill.run { background:#dbeafe; color:#1d4ed8; }
|
||||
.pill.fail { background:#fee2e2; color:var(--err); } .pill.mut { background:#ede9fe; color:var(--purple); }
|
||||
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
|
||||
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
|
||||
.bar-row .name { width:110px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.bar-row .track { flex:1; height:12px; background:#eef1f5; border-radius:6px; overflow:hidden; }
|
||||
.bar-row .fill { height:100%; background:var(--accent); border-radius:6px; }
|
||||
.bar-row .val { width:32px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
.search { width:100%; border:1px solid var(--border); border-radius:7px; padding:8px 12px; font-size:13px; margin-bottom:10px; background:#fff; }
|
||||
.search:focus { outline:none; border-color:var(--accent); }
|
||||
.view { display:none; } .view.active { display:block; }
|
||||
@media (max-width:900px){ .cards{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="head">
|
||||
<div class="brand"><span class="sq">am</span> agent-manager</div>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-view="overview">Vue d'ensemble</div>
|
||||
<div class="tab" data-view="stats">Statistiques</div>
|
||||
<div class="tab" data-view="sessions">Sessions</div>
|
||||
<div class="tab" data-view="events">Événements</div>
|
||||
<div class="tab" data-view="projects">Projets</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="hbadge ok">● local</span>
|
||||
<span class="hbadge">127.0.0.1:7878 · v0.6.0</span>
|
||||
<button class="btn ghost" onclick="fakeRefresh()">↻ Actualiser</button>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-title" id="view-title">Vue d'ensemble</div>
|
||||
<div class="page-sub" id="last-update">Dernière mise à jour : il y a 5 s · lang FR</div>
|
||||
|
||||
<section class="view active" id="overview">
|
||||
<div class="cards">
|
||||
<div class="card"><div class="l">Agents installés</div><div class="v">9</div><div class="d up">▲ +1 cette semaine</div></div>
|
||||
<div class="card"><div class="l">En cours</div><div class="v">2</div><div class="d up">● claude-code · codex</div></div>
|
||||
<div class="card"><div class="l">Sessions (7 j)</div><div class="v">47</div><div class="d up">▲ +12 %</div></div>
|
||||
<div class="card"><div class="l">Événements (24 h)</div><div class="v">132</div><div class="d down">▼ 8 % vs hier</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>Top agents — sessions (7 j)</h3>
|
||||
<div class="bar-row"><span class="name">claude-code</span><div class="track"><div class="fill" style="width:100%"></div></div><span class="val">18</span></div>
|
||||
<div class="bar-row"><span class="name">aider</span><div class="track"><div class="fill" style="width:50%"></div></div><span class="val">9</span></div>
|
||||
<div class="bar-row"><span class="name">codex</span><div class="track"><div class="fill" style="width:44%"></div></div><span class="val">8</span></div>
|
||||
<div class="bar-row"><span class="name">gemini-cli</span><div class="track"><div class="fill" style="width:28%"></div></div><span class="val">5</span></div>
|
||||
<div class="bar-row"><span class="name">jcode</span><div class="track"><div class="fill" style="width:22%"></div></div><span class="val">4</span></div>
|
||||
<div class="bar-row"><span class="name">opencode</span><div class="track"><div class="fill" style="width:17%"></div></div><span class="val">3</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Activité — sessions par jour (14 j)</h3>
|
||||
<svg viewBox="0 0 480 150" width="100%" height="150">
|
||||
<defs><linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2563eb" stop-opacity=".2"/><stop offset="1" stop-color="#2563eb" stop-opacity="0"/></linearGradient></defs>
|
||||
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g2)"/>
|
||||
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10.5px;font-family:ui-monospace,monospace;margin-top:4px"><span>04 août</span><span>11 août</span><span>aujourd'hui</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Sessions — 7 derniers jours</h3>
|
||||
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
|
||||
<tbody id="sess-body-ov">
|
||||
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
|
||||
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
|
||||
<tr><td class="mono">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="pill ok">terminée</span></td><td class="mono">42 min</td><td class="mono">12:12</td></tr>
|
||||
<tr><td class="mono">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">2 h 47 min</td><td class="mono">23:50</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="stats">
|
||||
<div class="panel">
|
||||
<h3>Statistiques par agent — période 30 j</h3>
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Sessions</th><th>Durée totale</th><th>Taux de succès</th><th>Coût estimé</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>54</td><td class="mono">38 h 12 m</td><td><span class="pill ok">94 %</span></td><td class="mono">12,40 €</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td>aider</td><td>31</td><td class="mono">21 h 48 m</td><td><span class="pill ok">87 %</span></td><td class="mono">4,10 €</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td>codex</td><td>27</td><td class="mono">19 h 05 m</td><td><span class="pill ok">89 %</span></td><td class="mono">8,75 €</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td>gemini-cli</td><td>14</td><td class="mono">6 h 33 m</td><td><span class="pill ok">79 %</span></td><td class="mono">0 € (local)</td><td class="mono">il y a 5 h</td></tr>
|
||||
<tr><td>jcode</td><td>12</td><td class="mono">5 h 02 m</td><td><span class="pill run">67 %</span></td><td class="mono">1,20 €</td><td class="mono">hier</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="sessions">
|
||||
<div class="panel">
|
||||
<h3>Sessions — 7 derniers jours</h3>
|
||||
<input class="search" placeholder="Filtrer par agent, projet ou id…" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>Session</th><th>Agent</th><th>Projet</th><th>Statut</th><th>Durée</th><th>Fin</th></tr></thead>
|
||||
<tbody id="sess-body">
|
||||
<tr><td class="mono">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="pill run">en cours</span></td><td class="mono">3 h 12 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="pill run">en cours</span></td><td class="mono">14 min</td><td class="mono">—</td></tr>
|
||||
<tr><td class="mono">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="pill ok">terminée</span></td><td class="mono">1 h 05 min</td><td class="mono">16:40</td></tr>
|
||||
<tr><td class="mono">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="pill fail">échec</span></td><td class="mono">12 min</td><td class="mono">14:33</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="events">
|
||||
<div class="panel">
|
||||
<h3>Journal d'événements — aujourd'hui</h3>
|
||||
<table>
|
||||
<thead><tr><th>Heure</th><th>Type</th><th>Agent</th><th>Détail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">17:18</td><td><span class="pill run">start</span></td><td>codex</td><td>projet flowdeck</td></tr>
|
||||
<tr><td class="mono">16:40</td><td><span class="pill ok">stop</span></td><td>claude-code</td><td>session 1 h 05 min</td></tr>
|
||||
<tr><td class="mono">15:34</td><td><span class="pill run">start</span></td><td>claude-code</td><td>projet agent-manager</td></tr>
|
||||
<tr><td class="mono">14:33</td><td><span class="pill fail">run</span></td><td>aider</td><td>exit code 1</td></tr>
|
||||
<tr><td class="mono">13:05</td><td><span class="pill mut">update</span></td><td>jcode</td><td>0.76.0 → 0.78.1</td></tr>
|
||||
<tr><td class="mono">10:02</td><td><span class="pill mut">install</span></td><td>opencode</td><td>0.14.2 — méthode binary</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="projects">
|
||||
<div class="panel">
|
||||
<h3>Projets</h3>
|
||||
<table>
|
||||
<thead><tr><th>Projet</th><th>Agents</th><th>Sessions (7 j)</th><th>Temps</th><th>Dernière activité</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>agent-manager</b> <span class="mono">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="mono">41 h</td><td class="mono">il y a 2 min</td></tr>
|
||||
<tr><td><b>flowdeck</b> <span class="mono">· Python</span></td><td>codex · aider</td><td>11</td><td class="mono">19 h</td><td class="mono">il y a 14 min</td></tr>
|
||||
<tr><td><b>obsigate</b> <span class="mono">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="mono">22 h</td><td class="mono">il y a 3 h</td></tr>
|
||||
<tr><td><b>imago</b> <span class="mono">· Python</span></td><td>gemini-cli</td><td>4</td><td class="mono">14 h</td><td class="mono">il y a 5 h</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles = { overview:"Vue d'ensemble", stats:"Statistiques", sessions:"Sessions", events:"Événements", projects:"Projets" };
|
||||
document.querySelectorAll('.tab').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(el.dataset.view).classList.add('active');
|
||||
document.getElementById('view-title').textContent = titles[el.dataset.view];
|
||||
});
|
||||
});
|
||||
function fakeRefresh(){
|
||||
document.getElementById('last-update').textContent = 'Dernière mise à jour : ' + new Date().toLocaleTimeString('fr-FR') + ' · lang FR';
|
||||
}
|
||||
function filterSessions(q){
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('#sess-body tr, #sess-body-ov tr').forEach(tr => {
|
||||
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am web — Terminal dense (mockup)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#0d1117; --panel:#10161f; --border:#212a36; --fg:#c9d4e0; --muted:#6e7b8a;
|
||||
--accent:#3fb950; --accent2:#58a6ff; --ok:#3fb950; --warn:#d29922; --err:#f85149; --purple:#bc8cff;
|
||||
}
|
||||
body {
|
||||
font-family:ui-monospace,SFMono-Regular,Consolas,"Cascadia Mono",Menlo,monospace;
|
||||
background:var(--bg); color:var(--fg); font-size:12.5px; line-height:1.5;
|
||||
}
|
||||
.top {
|
||||
border-bottom:1px solid var(--border); padding:9px 16px; display:flex; align-items:center; gap:16px;
|
||||
background:var(--panel); position:sticky; top:0; z-index:5;
|
||||
}
|
||||
.top .prompt { color:var(--accent); font-weight:700; }
|
||||
.top .brand { color:var(--fg); }
|
||||
.tabs { display:flex; gap:2px; }
|
||||
.tab { padding:3px 10px; cursor:pointer; color:var(--muted); border:1px solid transparent; border-radius:5px; }
|
||||
.tab:hover { color:var(--fg); }
|
||||
.tab.active { color:var(--accent); background:rgba(63,185,80,.08); border-color:rgba(63,185,80,.3); }
|
||||
.top .spacer { flex:1; }
|
||||
.stat { color:var(--muted); font-size:11.5px; }
|
||||
.stat b { color:var(--ok); font-weight:400; }
|
||||
.btn { background:transparent; border:1px solid var(--border); color:var(--fg); border-radius:5px; padding:3px 10px; font-family:inherit; font-size:12px; cursor:pointer; }
|
||||
.btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.wrap { max-width:1180px; margin:0 auto; padding:14px 16px 40px; }
|
||||
/* overview row */
|
||||
.kv { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:14px; }
|
||||
.kv-item { border:1px solid var(--border); background:var(--panel); border-radius:6px; padding:9px 12px; }
|
||||
.kv-item .k { color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; }
|
||||
.kv-item .v { font-size:21px; font-weight:700; color:var(--fg); margin-top:2px; }
|
||||
.kv-item .v small { color:var(--muted); font-size:11px; font-weight:400; }
|
||||
.kv-item.running .v { color:var(--accent); }
|
||||
.kv-item.running .v::before { content:"● "; }
|
||||
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:14px; }
|
||||
.box { border:1px solid var(--border); background:var(--panel); border-radius:6px; overflow:hidden; }
|
||||
.box .h { padding:7px 12px; border-bottom:1px solid var(--border); color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.8px; display:flex; justify-content:space-between; }
|
||||
.box .h .n { color:var(--accent); text-transform:none; letter-spacing:0; }
|
||||
.box .b { padding:10px 12px; }
|
||||
/* bars */
|
||||
.br { display:grid; grid-template-columns:95px 1fr 30px; gap:8px; align-items:center; margin-bottom:6px; }
|
||||
.br .nm { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.br .tr { height:10px; background:var(--bg); border-radius:3px; overflow:hidden; }
|
||||
.br .fl { height:100%; background:var(--accent); opacity:.85; }
|
||||
.br .vl { text-align:right; color:var(--muted); }
|
||||
/* tables */
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th { text-align:left; color:var(--muted); font-weight:400; font-size:10.5px; text-transform:uppercase; letter-spacing:.6px; padding:5px 10px; border-bottom:1px solid var(--border); }
|
||||
td { padding:5px 10px; border-bottom:1px solid rgba(33,42,54,.6); white-space:nowrap; }
|
||||
td.wrap { white-space:normal; }
|
||||
tbody tr:hover { background:rgba(63,185,80,.05); }
|
||||
.st { font-size:11px; } .st::before { content:"● "; }
|
||||
.st.ok { color:var(--ok); } .st.run { color:var(--accent2); } .st.fail { color:var(--err); } .st.mut { color:var(--purple); }
|
||||
.dim { color:var(--muted); }
|
||||
.view { display:none; } .view.active { display:block; }
|
||||
@media (max-width:900px){ .row{grid-template-columns:1fr;} .kv{grid-template-columns:repeat(2,1fr);} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<span class="prompt">$</span><span class="brand">am web</span>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-view="overview">overview</div>
|
||||
<div class="tab" data-view="stats">stats</div>
|
||||
<div class="tab" data-view="sessions">sessions</div>
|
||||
<div class="tab" data-view="events">events</div>
|
||||
<div class="tab" data-view="projects">projects</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="stat">v0.6.0 · <b>●</b> local · 127.0.0.1:7878</span>
|
||||
<button class="btn" onclick="fakeRefresh()">↻ refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<section class="view active" id="overview">
|
||||
<div class="kv">
|
||||
<div class="kv-item"><div class="k">agents installés</div><div class="v">9</div></div>
|
||||
<div class="kv-item running"><div class="k">en cours</div><div class="v">2</div></div>
|
||||
<div class="kv-item"><div class="k">sessions 7j</div><div class="v">47 <small>▲12%</small></div></div>
|
||||
<div class="kv-item"><div class="k">events 24h</div><div class="v">132 <small>▼8%</small></div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="box">
|
||||
<div class="h">top agents · sessions 7j</div>
|
||||
<div class="b">
|
||||
<div class="br"><span class="nm">claude-code</span><div class="tr"><div class="fl" style="width:100%"></div></div><span class="vl">18</span></div>
|
||||
<div class="br"><span class="nm">aider</span><div class="tr"><div class="fl" style="width:50%"></div></div><span class="vl">9</span></div>
|
||||
<div class="br"><span class="nm">codex</span><div class="tr"><div class="fl" style="width:44%"></div></div><span class="vl">8</span></div>
|
||||
<div class="br"><span class="nm">gemini-cli</span><div class="tr"><div class="fl" style="width:28%"></div></div><span class="vl">5</span></div>
|
||||
<div class="br"><span class="nm">jcode</span><div class="tr"><div class="fl" style="width:22%"></div></div><span class="vl">4</span></div>
|
||||
<div class="br"><span class="nm">opencode</span><div class="tr"><div class="fl" style="width:17%"></div></div><span class="vl">3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="h">activité 14j <span class="n">sessions/jour</span></div>
|
||||
<div class="b">
|
||||
<svg viewBox="0 0 480 150" width="100%" height="140">
|
||||
<defs><linearGradient id="g3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3fb950" stop-opacity=".3"/><stop offset="1" stop-color="#3fb950" stop-opacity="0"/></linearGradient></defs>
|
||||
<path d="M0,120 L37,108 L74,116 L111,96 L148,104 L185,84 L222,92 L259,72 L296,80 L333,62 L370,70 L407,48 L444,58 L480,34 L480,150 L0,150 Z" fill="url(#g3)"/>
|
||||
<polyline points="0,120 37,108 74,116 111,96 148,104 185,84 222,92 259,72 296,80 333,62 370,70 407,48 444,58 480,34" fill="none" stroke="#3fb950" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div style="display:flex;justify-content:space-between;color:var(--muted);font-size:10px"><span>04/08</span><span>11/08</span><span>18/08</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="h">sessions en cours</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>durée</th><th>depuis</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td>3h12m</td><td class="dim">14:26</td></tr>
|
||||
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td>14m</td><td class="dim">17:18</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="stats">
|
||||
<div class="box">
|
||||
<div class="h">stats par agent · 30j</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>agent</th><th>sessions</th><th>durée</th><th>succès</th><th>coût</th><th>dernière act.</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>claude-code</td><td>54</td><td>38h12m</td><td><span class="st ok">94%</span></td><td class="dim">12,40 €</td><td class="dim">2 min</td></tr>
|
||||
<tr><td>aider</td><td>31</td><td>21h48m</td><td><span class="st ok">87%</span></td><td class="dim">4,10 €</td><td class="dim">3 h</td></tr>
|
||||
<tr><td>codex</td><td>27</td><td>19h05m</td><td><span class="st ok">89%</span></td><td class="dim">8,75 €</td><td class="dim">14 min</td></tr>
|
||||
<tr><td>gemini-cli</td><td>14</td><td>6h33m</td><td><span class="st ok">79%</span></td><td class="dim">0 € local</td><td class="dim">5 h</td></tr>
|
||||
<tr><td>jcode</td><td>12</td><td>5h02m</td><td><span class="st run">67%</span></td><td class="dim">1,20 €</td><td class="dim">hier</td></tr>
|
||||
<tr><td>opencode</td><td>9</td><td>4h40m</td><td><span class="st fail">55%</span></td><td class="dim">0,90 €</td><td class="dim">2 j</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="sessions">
|
||||
<div class="box">
|
||||
<div class="h">sessions · 7j</div>
|
||||
<div class="b" style="padding:8px 12px 0">
|
||||
<input id="sess-search" placeholder="$ grep sessions…" style="width:100%;background:var(--bg);border:1px solid var(--border);color:var(--fg);font-family:inherit;font-size:12px;padding:6px 10px;border-radius:4px;margin-bottom:6px" oninput="filterSessions(this.value)">
|
||||
</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>id</th><th>agent</th><th>projet</th><th>statut</th><th>durée</th><th>fin</th></tr></thead>
|
||||
<tbody id="sess-body">
|
||||
<tr><td class="dim">20260818_172631_b4e2f1</td><td>claude-code</td><td>agent-manager</td><td><span class="st run">running</span></td><td>3h12m</td><td class="dim">—</td></tr>
|
||||
<tr><td class="dim">20260818_171812_c9d0a4</td><td>codex</td><td>flowdeck</td><td><span class="st run">running</span></td><td>14m</td><td class="dim">—</td></tr>
|
||||
<tr><td class="dim">20260818_153402_a1b2c3</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>1h05m</td><td class="dim">16:40</td></tr>
|
||||
<tr><td class="dim">20260818_142118_d4e5f6</td><td>aider</td><td>obsigate</td><td><span class="st fail">failed</span></td><td>12m</td><td class="dim">14:33</td></tr>
|
||||
<tr><td class="dim">20260818_113047_e7f8a9</td><td>gemini-cli</td><td>imago</td><td><span class="st ok">done</span></td><td>42m</td><td class="dim">12:12</td></tr>
|
||||
<tr><td class="dim">20260817_220933_b0c1d2</td><td>claude-code</td><td>agent-manager</td><td><span class="st ok">done</span></td><td>2h47m</td><td class="dim">23:50</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="events">
|
||||
<div class="box">
|
||||
<div class="h">events · aujourd'hui</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>heure</th><th>type</th><th>agent</th><th class="wrap">détail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="dim">17:18</td><td><span class="st run">start</span></td><td>codex</td><td class="wrap">projet flowdeck</td></tr>
|
||||
<tr><td class="dim">16:40</td><td><span class="st ok">stop</span></td><td>claude-code</td><td class="wrap">session 1h05m</td></tr>
|
||||
<tr><td class="dim">15:34</td><td><span class="st run">start</span></td><td>claude-code</td><td class="wrap">projet agent-manager</td></tr>
|
||||
<tr><td class="dim">14:33</td><td><span class="st fail">run</span></td><td>aider</td><td class="wrap">exit code 1 — fichier manquant</td></tr>
|
||||
<tr><td class="dim">13:05</td><td><span class="st mut">update</span></td><td>jcode</td><td class="wrap">0.76.0 → 0.78.1</td></tr>
|
||||
<tr><td class="dim">10:02</td><td><span class="st mut">install</span></td><td>opencode</td><td class="wrap">0.14.2 — méthode binary</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="projects">
|
||||
<div class="box">
|
||||
<div class="h">projects</div>
|
||||
<div class="b" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>projet</th><th>agents</th><th>sessions 7j</th><th>temps</th><th>dernière act.</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>agent-manager <span class="dim">· Rust</span></td><td>claude-code · jcode · codex</td><td>24</td><td class="dim">41h</td><td class="dim">2 min</td></tr>
|
||||
<tr><td>flowdeck <span class="dim">· Python</span></td><td>codex · aider</td><td>11</td><td class="dim">19h</td><td class="dim">14 min</td></tr>
|
||||
<tr><td>obsigate <span class="dim">· Python</span></td><td>aider · gemini-cli</td><td>8</td><td class="dim">22h</td><td class="dim">3 h</td></tr>
|
||||
<tr><td>imago <span class="dim">· Python</span></td><td>gemini-cli</td><td>4</td><td class="dim">14h</td><td class="dim">5 h</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tab').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById(el.dataset.view).classList.add('active');
|
||||
});
|
||||
});
|
||||
function fakeRefresh(){}
|
||||
function filterSessions(q){
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('#sess-body tr').forEach(tr => {
|
||||
tr.style.display = tr.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,622 @@
|
||||
//! Post-install agent configuration (issue #91): the `config:` block of an
|
||||
//! agent definition wires the resolved provider/model into the agent —
|
||||
//! the environment variables it expects (env_map) and the config files to
|
||||
//! write or softly edit (files).
|
||||
//!
|
||||
//! Pure functions + per-format tests (probe.rs pattern). Security rule: a
|
||||
//! token is never written in clear — the api_key slot stays the `@secret`
|
||||
//! reference (issue #89), resolved from the OS keyring at start/run time.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{AgentDef, Config};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// The four format families covering ~every agent (spec #91).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileFormat {
|
||||
Toml,
|
||||
Yaml,
|
||||
Json,
|
||||
KeyValue,
|
||||
}
|
||||
|
||||
impl FileFormat {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Toml => "toml",
|
||||
FileFormat::Yaml => "yaml",
|
||||
FileFormat::Json => "json",
|
||||
FileFormat::KeyValue => "key=value",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the format family from the file extension.
|
||||
pub fn format_of(path: &Path) -> Option<FileFormat> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"toml" => Some(FileFormat::Toml),
|
||||
"yaml" | "yml" => Some(FileFormat::Yaml),
|
||||
"json" => Some(FileFormat::Json),
|
||||
"conf" | "ini" | "env" | "properties" | "cfg" => Some(FileFormat::KeyValue),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider preferred by an agent: `provider:` of the definition, then the
|
||||
/// `config.provider_default` of its config block (issue #91).
|
||||
pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
|
||||
agent
|
||||
.provider
|
||||
.as_deref()
|
||||
.or(agent.config.as_ref().and_then(|c| c.provider_default.as_deref()))
|
||||
}
|
||||
|
||||
/// Merge the resolved `config.env_map` of an agent into its default env
|
||||
/// (issue #91): api_key -> `@secret` (resolved by resolve_env_secrets),
|
||||
/// model/base_url/provider -> values of the resolved provider. Explicit
|
||||
/// values already present in agent.env are never overwritten.
|
||||
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
|
||||
apply_env_map_resolved(env, agent, config, None, None);
|
||||
}
|
||||
|
||||
/// Same as `apply_env_map` but with the provider and model fixed by the
|
||||
/// caller (issue #92: `am run/start --provider/--model`): the flags win
|
||||
/// over the agent declaration and the settings default.
|
||||
pub fn apply_env_map_resolved(
|
||||
env: &mut BTreeMap<String, String>,
|
||||
agent: &AgentDef,
|
||||
config: &Config,
|
||||
provider_override: Option<&str>,
|
||||
model_override: Option<&str>,
|
||||
) {
|
||||
let Some(cfg) = &agent.config else {
|
||||
return;
|
||||
};
|
||||
if cfg.env_map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(pname) = provider_override
|
||||
.filter(|p| !p.is_empty())
|
||||
.or_else(|| provider_pref(agent))
|
||||
.or_else(|| crate::providers::default_name(config))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(def) = crate::providers::get(config, pname) else {
|
||||
return;
|
||||
};
|
||||
let model = model_override
|
||||
.filter(|m| !m.is_empty())
|
||||
.or(agent.model.as_deref())
|
||||
.or(def.default_model.as_deref());
|
||||
for (slot, var) in &cfg.env_map {
|
||||
let value = match slot.as_str() {
|
||||
"api_key" => "@secret".to_string(),
|
||||
"model" => {
|
||||
let Some(m) = model else { continue };
|
||||
m.to_string()
|
||||
}
|
||||
"base_url" => def.base_url.clone(),
|
||||
"provider" => pname.to_string(),
|
||||
_ => continue, // unknown slots are ignored
|
||||
};
|
||||
env.entry(var.clone()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Substitute the {model}, {provider} and {base_url} placeholders of a
|
||||
/// setup hint with the resolved provider configuration (v1.1.4). When no
|
||||
/// provider is resolved, the hint is returned unchanged.
|
||||
pub fn render_hint(
|
||||
hint: &str,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
config: &Config,
|
||||
) -> String {
|
||||
let Some((pname, model)) = resolved else {
|
||||
return hint.to_string();
|
||||
};
|
||||
let base_url = crate::providers::get(config, pname)
|
||||
.map(|d| d.base_url.clone())
|
||||
.unwrap_or_default();
|
||||
hint.replace("{provider}", pname)
|
||||
.replace(
|
||||
"{model}",
|
||||
model.as_deref().unwrap_or("{model}"),
|
||||
)
|
||||
.replace("{base_url}", &base_url)
|
||||
}
|
||||
|
||||
/// Print the manual setup commands of an agent (agents that manage their
|
||||
/// own configuration, e.g. picoclaw onboard/model/auth). No-op when the
|
||||
/// agent declares none (v1.1.4).
|
||||
fn print_hints(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
) {
|
||||
if agent.setup_hints.is_empty() {
|
||||
return;
|
||||
}
|
||||
app.log
|
||||
.success(crate::i18n::tr("Configuration manuelle requise — à exécuter :"));
|
||||
for hint in &agent.setup_hints {
|
||||
app.log.info(&format!(" {}", render_hint(hint, resolved, &app.config)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the agent configuration files after a successful install
|
||||
/// (issue #91). The caller skips this on `--no-config`, on non-configurable
|
||||
/// agents, and in dry-run (never reached). Never writes a token in clear:
|
||||
/// the api_key slot is written as the `@secret` reference.
|
||||
pub fn apply_post_install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
resolved: &Option<(String, Option<String>)>,
|
||||
) -> Result<()> {
|
||||
let Some(cfg) = &agent.config else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"agent sans adaptateur de config — voir sa doc",
|
||||
));
|
||||
print_hints(app, agent, resolved);
|
||||
return Ok(());
|
||||
};
|
||||
if cfg.files.is_empty() {
|
||||
if cfg.env_map.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"agent sans adaptateur de config — voir sa doc",
|
||||
));
|
||||
} else {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"config par variables d'env — injectée au démarrage",
|
||||
));
|
||||
}
|
||||
print_hints(app, agent, resolved);
|
||||
return Ok(());
|
||||
}
|
||||
let Some((pname, model)) = resolved else {
|
||||
app.log.warn(crate::i18n::tr(
|
||||
"aucun provider résolu — fichiers de config non écrits",
|
||||
));
|
||||
print_hints(app, agent, resolved);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(def) = crate::providers::get(&app.config, pname) else {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"provider '{}' introuvable — fichiers de config non écrits",
|
||||
pname
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
for f in &cfg.files {
|
||||
let path = crate::config::expand_path(&f.path);
|
||||
let Some(fmt) = format_of(&path) else {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"format inconnu pour {} — adaptateurs: toml, yaml, json, key=value",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let mut updates: Vec<(String, String)> = Vec::new();
|
||||
for key in &f.keys {
|
||||
let value = match key.as_str() {
|
||||
"api_key" => "@secret".to_string(),
|
||||
"model" => model.clone().unwrap_or_default(),
|
||||
"base_url" => def.base_url.clone(),
|
||||
"provider" => pname.clone(),
|
||||
other => {
|
||||
app.log.warn(&crate::tr_fmt!(
|
||||
"clé '{}' inconnue dans config.files de {}",
|
||||
other,
|
||||
agent.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
updates.push((key.clone(), value));
|
||||
}
|
||||
write_soft(&path, fmt, &updates)?;
|
||||
app.log
|
||||
.success(&crate::tr_fmt!("config écrite: {}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft edit of a config file: existing unknown keys are preserved, known
|
||||
/// keys are updated, missing keys are appended. Creates parent dirs.
|
||||
pub fn write_soft(path: &Path, format: FileFormat, updates: &[(String, String)]) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
let text = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let out = match format {
|
||||
FileFormat::Toml => edit_toml(&text, updates)?,
|
||||
FileFormat::Yaml => edit_yaml(&text, updates)?,
|
||||
FileFormat::Json => edit_json(&text, updates)?,
|
||||
FileFormat::KeyValue => edit_key_value(&text, updates),
|
||||
};
|
||||
std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TOML soft edit. Note: toml::Value does not retain comments on
|
||||
/// serialization — unknown KEYS are preserved, comments are dropped.
|
||||
fn edit_toml(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: toml::Value = if text.trim().is_empty() {
|
||||
toml::Value::Table(Default::default())
|
||||
} else {
|
||||
text.parse::<toml::Value>()
|
||||
.map_err(|e| anyhow!("invalid TOML: {e}"))?
|
||||
};
|
||||
let table = root
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| anyhow!("TOML root is not a table"))?;
|
||||
for (k, v) in updates {
|
||||
table.insert(k.clone(), toml::Value::String(v.clone()));
|
||||
}
|
||||
Ok(root.to_string())
|
||||
}
|
||||
|
||||
fn edit_yaml(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: serde_yaml::Value = if text.trim().is_empty() {
|
||||
serde_yaml::Value::Mapping(Default::default())
|
||||
} else {
|
||||
serde_yaml::from_str(text).map_err(|e| anyhow!("invalid YAML: {e}"))?
|
||||
};
|
||||
let map = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("YAML root is not a mapping"))?;
|
||||
for (k, v) in updates {
|
||||
map.insert(
|
||||
serde_yaml::Value::String(k.clone()),
|
||||
serde_yaml::Value::String(v.clone()),
|
||||
);
|
||||
}
|
||||
serde_yaml::to_string(&root).map_err(|e| anyhow!("cannot serialize YAML: {e}"))
|
||||
}
|
||||
|
||||
fn edit_json(text: &str, updates: &[(String, String)]) -> Result<String> {
|
||||
let mut root: serde_json::Value = if text.trim().is_empty() {
|
||||
serde_json::Value::Object(Default::default())
|
||||
} else {
|
||||
serde_json::from_str(text).map_err(|e| anyhow!("invalid JSON: {e}"))?
|
||||
};
|
||||
let obj = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("JSON root is not an object"))?;
|
||||
for (k, v) in updates {
|
||||
obj.insert(k.clone(), serde_json::Value::String(v.clone()));
|
||||
}
|
||||
serde_json::to_string_pretty(&root).map_err(|e| anyhow!("cannot serialize JSON: {e}"))
|
||||
}
|
||||
|
||||
fn edit_key_value(text: &str, updates: &[(String, String)]) -> String {
|
||||
let mut lines: Vec<String> = text.lines().map(String::from).collect();
|
||||
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for (i, line) in lines.iter_mut().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.contains('=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = trimmed.split('=').next().unwrap_or("").trim();
|
||||
// Case-insensitive match (API_KEY vs api_key in .env files); the
|
||||
// existing line keeps its casing.
|
||||
if let Some((k, v)) = updates.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
|
||||
*line = format!("{key}={v}");
|
||||
seen.insert(k, i);
|
||||
}
|
||||
}
|
||||
for (k, v) in updates {
|
||||
if !seen.contains_key(k.as_str()) {
|
||||
lines.push(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use crate::config::{AgentConfig, Config};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn updates() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("model".to_string(), "gpt-5.2".to_string()),
|
||||
("base_url".to_string(), "https://api.openai.com/v1".to_string()),
|
||||
("api_key".to_string(), "@secret".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn tmp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join(name);
|
||||
if !content.is_empty() {
|
||||
std::fs::write(&p, content).unwrap();
|
||||
}
|
||||
(dir, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_detection_covers_the_four_families() {
|
||||
assert_eq!(format_of(Path::new("a.toml")), Some(FileFormat::Toml));
|
||||
assert_eq!(format_of(Path::new("a.yaml")), Some(FileFormat::Yaml));
|
||||
assert_eq!(format_of(Path::new("a.yml")), Some(FileFormat::Yaml));
|
||||
assert_eq!(format_of(Path::new("a.json")), Some(FileFormat::Json));
|
||||
assert_eq!(format_of(Path::new("a.conf")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.env")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.ini")), Some(FileFormat::KeyValue));
|
||||
assert_eq!(format_of(Path::new("a.md")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.toml", "# existing\nmodel = \"old\"\ntemperature = 0.7\n");
|
||||
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
|
||||
assert!(
|
||||
out.contains("base_url = \"https://api.openai.com/v1\""),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("api_key = \"@secret\""), "{out}");
|
||||
assert!(out.contains("temperature = 0.7"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_creates_the_file_when_missing() {
|
||||
let (_d, p) = tmp("new.toml", "");
|
||||
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
|
||||
assert!(out.contains("api_key = \"@secret\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.yaml", "model: old\nverbose: true\n");
|
||||
write_soft(&p, FileFormat::Yaml, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model: gpt-5.2"), "{out}");
|
||||
assert!(out.contains("base_url: https://api.openai.com/v1"), "{out}");
|
||||
assert!(out.contains("verbose: true"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_keeps_unknown_keys() {
|
||||
let (_d, p) = tmp("c.json", "{\"model\": \"old\", \"verbose\": true}");
|
||||
write_soft(&p, FileFormat::Json, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("\"model\": \"gpt-5.2\""), "{out}");
|
||||
assert!(out.contains("\"api_key\": \"@secret\""), "{out}");
|
||||
assert!(out.contains("\"verbose\": true"), "unknown key lost: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_round_trip_keeps_unknown_lines() {
|
||||
let (_d, p) = tmp(
|
||||
"c.conf",
|
||||
"# header\nMODEL=old\nOTHER=keep\n",
|
||||
);
|
||||
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("MODEL=gpt-5.2"), "{out}");
|
||||
assert!(out.contains("OTHER=keep"), "unknown line lost: {out}");
|
||||
assert!(out.contains("# header"), "comment lost: {out}");
|
||||
assert!(out.contains("api_key=@secret"), "{out}");
|
||||
// No duplicate MODEL line.
|
||||
assert_eq!(out.matches("MODEL=").count(), 1, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_appends_missing_keys() {
|
||||
let (_d, p) = tmp("empty.env", "");
|
||||
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
|
||||
let out = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(out.contains("model=gpt-5.2"), "{out}");
|
||||
assert!(out.contains("base_url=https://api.openai.com/v1"), "{out}");
|
||||
assert!(out.contains("api_key=@secret"), "{out}");
|
||||
}
|
||||
|
||||
fn agent_with_config(config: AgentConfig, provider: Option<String>) -> AgentDef {
|
||||
AgentDef {
|
||||
name: "x".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
category: None,
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("x".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: None,
|
||||
model_arg: None,
|
||||
provider,
|
||||
model: None,
|
||||
config: Some(config),
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_map_wires_the_resolved_provider() {
|
||||
let mut c: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
|
||||
env_map.insert("model".to_string(), "MY_MODEL".to_string());
|
||||
env_map.insert("base_url".to_string(), "MY_URL".to_string());
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map,
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
apply_env_map(&mut env, &agent, &c);
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
// Explicit agent.env values win.
|
||||
let mut env2 = BTreeMap::new();
|
||||
env2.insert("MY_MODEL".to_string(), "explicit".to_string());
|
||||
apply_env_map(&mut env2, &agent, &c);
|
||||
assert_eq!(env2.get("MY_MODEL").unwrap(), "explicit");
|
||||
// Unset default provider + no provider_default: no-op.
|
||||
c.settings.default_provider = None;
|
||||
let mut env3 = BTreeMap::new();
|
||||
apply_env_map(&mut env3, &agent, &c);
|
||||
assert!(env3.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_post_install_without_config_block_is_a_noop() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app = crate::app::App::from_cli(cli).unwrap();
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
// No config block at all → Ok, nothing written.
|
||||
let mut bare = agent.clone();
|
||||
bare.config = None;
|
||||
assert!(apply_post_install(&app, &bare, &None).is_ok());
|
||||
// Config block but no files → Ok, nothing written.
|
||||
assert!(apply_post_install(&app, &agent, &None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_pref_prefers_the_declared_provider() {
|
||||
let agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: Some("openai".to_string()),
|
||||
},
|
||||
Some("anthropic".to_string()),
|
||||
);
|
||||
assert_eq!(provider_pref(&agent), Some("anthropic"));
|
||||
let agent2 = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: Some("openai".to_string()),
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(provider_pref(&agent2), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_hint_substitutes_resolved_values() {
|
||||
let c: Config = serde_yaml::from_str(
|
||||
"version: \"1.0\"\nsettings:\n default_provider: openrouter\n providers:\n openrouter:\n base_url: https://openrouter.ai/api/v1\n default_model: deepseek/deepseek-chat-v3-0324\n models: [deepseek/deepseek-chat-v3-0324]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = Some(("openrouter".to_string(), Some("deepseek/deepseek-chat-v3-0324".to_string())));
|
||||
assert_eq!(
|
||||
render_hint("picoclaw model {model}", &resolved, &c),
|
||||
"picoclaw model deepseek/deepseek-chat-v3-0324"
|
||||
);
|
||||
assert_eq!(render_hint("picoclaw onboard", &resolved, &c), "picoclaw onboard");
|
||||
assert_eq!(
|
||||
render_hint("cli --provider {provider} --url {base_url}", &resolved, &c),
|
||||
"cli --provider openrouter --url https://openrouter.ai/api/v1"
|
||||
);
|
||||
// No resolved provider: the hint is left untouched.
|
||||
assert_eq!(
|
||||
render_hint("picoclaw model {model}", &None, &c),
|
||||
"picoclaw model {model}"
|
||||
);
|
||||
// Resolved provider without a model: {model} stays visible.
|
||||
let no_model = Some(("openrouter".to_string(), None));
|
||||
assert_eq!(
|
||||
render_hint("picoclaw model {model}", &no_model, &c),
|
||||
"picoclaw model {model}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_post_install_with_setup_hints_is_ok() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app = crate::app::App::from_cli(cli).unwrap();
|
||||
let mut agent = agent_with_config(
|
||||
AgentConfig {
|
||||
env_map: BTreeMap::new(),
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
agent.config = None;
|
||||
agent.setup_hints = vec![
|
||||
"picoclaw onboard".to_string(),
|
||||
"picoclaw model {model}".to_string(),
|
||||
];
|
||||
// No provider resolved: hints printed with placeholders, no crash.
|
||||
assert!(apply_post_install(&app, &agent, &None).is_ok());
|
||||
assert!(apply_post_install(&app, &agent, &Some(("openrouter".to_string(), None))).is_ok());
|
||||
}
|
||||
}
|
||||
+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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
-15
@@ -103,7 +103,35 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
|
||||
/// configuration file, then validate the result.
|
||||
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
|
||||
let path = user_config_path(app)?;
|
||||
let text = std::fs::read_to_string(&path)
|
||||
write_dotted_key(&path, key, value)?;
|
||||
reload_and_validate(&path)?;
|
||||
app.log.success(&format!("{key} = {value}"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Persist one setting into the configuration file that is actually in
|
||||
/// effect (the loaded origin), creating the user config file when none
|
||||
/// 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 = 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)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
|
||||
.with_context(|| format!("{} is not valid YAML", path.display()))?;
|
||||
@@ -128,16 +156,18 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
|
||||
serde_yaml::Value::String(parts.last().unwrap().to_string()),
|
||||
parsed,
|
||||
);
|
||||
std::fs::write(&path, serde_yaml::to_string(&root)?)
|
||||
std::fs::write(path, serde_yaml::to_string(&root)?)
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Validate the new configuration end to end.
|
||||
/// Reload a written config file to make sure the change stays valid.
|
||||
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:#}")
|
||||
})?;
|
||||
app.log.success(&format!("{key} = {value}"));
|
||||
Ok(0)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a scalar: booleans and integers keep their type, everything else
|
||||
@@ -160,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
|
||||
@@ -194,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)
|
||||
@@ -204,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);
|
||||
}
|
||||
@@ -213,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(),
|
||||
)]),
|
||||
);
|
||||
}
|
||||
@@ -224,14 +289,93 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
/// App whose active config is a file inside a leaked TempDir (the same
|
||||
/// pattern as profile_cmd's tests: the guard would delete the directory
|
||||
/// when the helper returns).
|
||||
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)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_setting_writes_into_the_active_config() {
|
||||
let (app, cfg) = app_with_config("version: \"1.0\"
|
||||
settings:
|
||||
theme: default
|
||||
agents: []
|
||||
");
|
||||
persist_setting(&app, "settings.theme", "ocean").unwrap();
|
||||
let text = std::fs::read_to_string(&cfg).unwrap();
|
||||
let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap();
|
||||
assert_eq!(value["settings"]["theme"], "ocean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_theme_is_used_on_next_start() {
|
||||
let (app, cfg) = app_with_config("version: \"1.0\"
|
||||
settings:
|
||||
theme: default
|
||||
agents: []
|
||||
");
|
||||
persist_setting(&app, "settings.theme", "ocean").unwrap();
|
||||
// A fresh start reads settings.theme from the config file.
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
assert_eq!(reloaded.theme().name, "ocean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_setting_creates_the_user_config_when_none_exists() {
|
||||
let (mut app, _cfg) = app_with_config("version: \"1.0\"
|
||||
agents: []
|
||||
");
|
||||
// Simulate a run without any user/local config: no origin.
|
||||
app.config_origin = None;
|
||||
persist_setting(&app, "settings.theme", "mono").unwrap();
|
||||
let user_file = app.paths.config_dir.as_ref().unwrap().join(config::CONFIG_FILE_NAME);
|
||||
assert!(user_file.is_file(), "user config was not created");
|
||||
let text = std::fs::read_to_string(&user_file).unwrap();
|
||||
let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap();
|
||||
assert_eq!(value["settings"]["theme"], "mono");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_dotted_key_descends_into_nested_mappings() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let path = guard.path().join("c.yaml");
|
||||
std::fs::write(&path, "version: \"1.0\"
|
||||
agents: []
|
||||
").unwrap();
|
||||
write_dotted_key(&path, "settings.default_shell", "pwsh").unwrap();
|
||||
let value: serde_yaml::Value =
|
||||
serde_yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(value["settings"]["default_shell"], "pwsh");
|
||||
assert!(value["agents"].is_sequence());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+188
-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),
|
||||
@@ -200,6 +319,12 @@ pub fn resolve_exec(
|
||||
Some(e) if !e.bins.is_empty() => {
|
||||
let p = std::path::PathBuf::from(&e.bins[0]);
|
||||
if p.exists() || app.dry_run() {
|
||||
// The recorded bin replaces the run command's first token
|
||||
// (e.g. "picoclaw"): consume it so it is not appended to
|
||||
// the argument list. Regression: am start <agent géré>
|
||||
// lançait "<bin> <token-run>" — picoclaw rejetait son propre
|
||||
// nom comme sous-commande inconnue.
|
||||
tokens.remove(0);
|
||||
p.display().to_string()
|
||||
} else {
|
||||
tokens.remove(0)
|
||||
@@ -220,9 +345,18 @@ pub fn resolve_exec(
|
||||
args.extend(agent.args.iter().cloned());
|
||||
args.extend(extra_args.iter().cloned());
|
||||
let mut env = agent.env.clone();
|
||||
// Issue #91: the `config.env_map` block, resolved against the agent's
|
||||
// provider — api_key stays the @secret reference (resolved below).
|
||||
crate::agent_config::apply_env_map(&mut env, agent, &app.config);
|
||||
env.extend(extra_env.clone());
|
||||
let warnings =
|
||||
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
|
||||
crate::secrets::resolve_env_secrets(
|
||||
&crate::secrets::store(),
|
||||
&agent.name,
|
||||
crate::agent_config::provider_pref(agent)
|
||||
.or_else(|| crate::providers::default_name(&app.config)),
|
||||
&mut env,
|
||||
);
|
||||
for w in warnings {
|
||||
app.log.warn(&w);
|
||||
}
|
||||
@@ -345,6 +479,7 @@ pub fn select_method<'a>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn routes_cmd_scripts_through_comspec() {
|
||||
@@ -370,4 +505,47 @@ mod tests {
|
||||
assert_eq!(program, "node");
|
||||
assert_eq!(args, vec!["-v".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_exec_installed_agent_does_not_leak_run_token() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents:\n - name: x\n run: x\n installable: false\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
app.paths.state_file = dir.join("state.json");
|
||||
app.state = crate::state::StateStore::new(app.paths.state_file.clone());
|
||||
// A recorded installed bin that exists: the run token "x" must be
|
||||
// consumed, not appended to the argument list.
|
||||
let bin = dir.join("x.exe");
|
||||
std::fs::write(&bin, b"x").unwrap();
|
||||
app.state
|
||||
.set(&crate::state::InstalledEntry {
|
||||
name: "x".to_string(),
|
||||
version: Some("1.0".to_string()),
|
||||
method: "binary".to_string(),
|
||||
install_dir: dir.display().to_string(),
|
||||
bins: vec![bin.display().to_string()],
|
||||
run: "x".to_string(),
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let agent = app.catalog.resolve("x").unwrap();
|
||||
let exec = resolve_exec(&app, agent, &[], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(exec.program, bin.display().to_string());
|
||||
assert!(
|
||||
exec.args.is_empty(),
|
||||
"run token leaked into args: {:?}",
|
||||
exec.args
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+419
-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,144 @@ fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
.num_seconds();
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AgentConfig, AgentDef, Config};
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(providers_yaml: &str) -> App {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
crate::app::App::from_cli(cli).unwrap()
|
||||
}
|
||||
|
||||
fn agent(model_env: bool, model_arg: bool) -> AgentDef {
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
|
||||
env_map.insert("model".to_string(), "MY_MODEL".to_string());
|
||||
env_map.insert("base_url".to_string(), "MY_URL".to_string());
|
||||
AgentDef {
|
||||
name: "demo".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
category: None,
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some("demo".to_string()),
|
||||
detect: None,
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: model_env.then(|| "MY_MODEL".to_string()),
|
||||
model_arg: model_arg.then(|| "--model".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
config: Some(AgentConfig {
|
||||
env_map,
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
}),
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
const OPENAI: &str = " default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n";
|
||||
|
||||
#[test]
|
||||
fn apply_provider_resolves_and_never_leaks_the_token() {
|
||||
let app = test_app(OPENAI);
|
||||
let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// The token slot is the @secret reference — never a value.
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_uses_the_declared_model_channel() {
|
||||
let app = test_app(OPENAI);
|
||||
// model_env wins over the env_map slot.
|
||||
let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert!(args.is_empty());
|
||||
// model_arg is pushed on the command line.
|
||||
let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_unknown_is_an_error_listing_the_registry() {
|
||||
let app = test_app(OPENAI);
|
||||
let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("nope"), "{msg}");
|
||||
assert!(msg.contains("openai"), "registry list missing: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_provider_falls_back_to_the_embedded_default_provider() {
|
||||
// The embedded default catalog ships providers (anthropic★ since
|
||||
// issue #90): even an empty user config resolves through the cloud.
|
||||
let app = test_app("");
|
||||
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x"))
|
||||
.unwrap()
|
||||
.expect("the embedded default provider resolves");
|
||||
// Model "x" is unknown: the env_map model slot is skipped, but the
|
||||
// provider slot is wired.
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1");
|
||||
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_flag_falls_back_to_the_cloud_registry() {
|
||||
let app = test_app(OPENAI);
|
||||
// apply_model bails (no local runtime in the test env) → the caller
|
||||
// falls back to apply_provider with the default provider.
|
||||
let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err();
|
||||
assert!(local.to_string().contains("not found on any local runtime"));
|
||||
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
||||
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_parses_the_provider_registry_and_agents() {
|
||||
let _cfg: Config = serde_yaml::from_str(&format!(
|
||||
"version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,243 @@
|
||||
//! 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,
|
||||
setup_hints: vec![],
|
||||
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)
|
||||
}
|
||||
+581
-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,38 @@ 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>,
|
||||
/// Manual setup commands shown after install when the agent manages its
|
||||
/// own configuration (onboard/model/auth...). Placeholders {model},
|
||||
/// {provider} and {base_url} are substituted with the resolved values
|
||||
/// (v1.1.4).
|
||||
#[serde(default)]
|
||||
pub setup_hints: Vec<String>,
|
||||
/// Sandbox profile (issue #79): allowed commands, working directories
|
||||
/// and network policy. Disabled by default — the unsandboxed mode
|
||||
/// stays available and documented.
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<SandboxProfile>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// When false, the agent is listed but cannot be installed locally.
|
||||
@@ -186,6 +492,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 +561,12 @@ impl AgentDef {
|
||||
})
|
||||
}
|
||||
|
||||
/// Binary probed on the PATH for external detection: the explicit
|
||||
/// `detect` name when set, otherwise the first token of `run`.
|
||||
pub fn detect_token(&self) -> Option<String> {
|
||||
self.detect.clone().or_else(|| self.first_token())
|
||||
}
|
||||
|
||||
/// The run command split into tokens (empty when unset).
|
||||
pub fn run_tokens(&self) -> Vec<String> {
|
||||
self.run
|
||||
@@ -385,6 +741,10 @@ pub struct InstallSpec {
|
||||
/// Commands executed after a successful install (PATH includes the bin dir).
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
/// The agent accepts a post-install provider configuration (issue #91).
|
||||
/// False for agents without any API (issue #90).
|
||||
#[serde(default = "default_true")]
|
||||
pub configurable: bool,
|
||||
}
|
||||
|
||||
impl InstallSpec {
|
||||
@@ -398,6 +758,33 @@ impl InstallSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-install provider configuration of an agent (issue #91): the env vars
|
||||
/// the agent expects and the config files to write or softly edit. The
|
||||
/// api_key slot is always injected as the `@secret` reference (issue #89) —
|
||||
/// a token is never written in clear.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AgentConfig {
|
||||
/// Expected env vars, keyed by semantic slot (api_key / model /
|
||||
/// base_url / provider) -> the agent's variable name.
|
||||
pub env_map: BTreeMap<String, String>,
|
||||
/// Configuration files to write or softly edit after the install.
|
||||
pub files: Vec<AgentConfigFile>,
|
||||
/// Provider of this agent, otherwise settings.default_provider.
|
||||
pub provider_default: Option<String>,
|
||||
}
|
||||
|
||||
/// One config file of an agent (issue #91).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentConfigFile {
|
||||
/// File path (supports ~ and env vars).
|
||||
pub path: String,
|
||||
/// Keys to write among: api_key, model, base_url, provider.
|
||||
#[serde(default)]
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
@@ -481,7 +868,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 +881,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) {
|
||||
@@ -533,6 +959,78 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.self_update_base_url.is_some() {
|
||||
s.self_update_base_url = o.self_update_base_url;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -598,6 +1096,32 @@ pub fn validate(config: &Config) -> Vec<String> {
|
||||
problems.push(format!("agent '{}': dependency without a name", agent.name));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &config.settings.default_provider {
|
||||
if config.settings.provider(d).is_none() {
|
||||
problems.push(format!(
|
||||
"settings.default_provider '{d}' is not defined in settings.providers"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(providers) = &config.settings.providers {
|
||||
for (name, p) in providers {
|
||||
// A null entry is a deletion marker (issue #90): skip it.
|
||||
let Some(p) = p else {
|
||||
continue;
|
||||
};
|
||||
if p.base_url.trim().is_empty() {
|
||||
problems.push(format!("provider '{name}': 'base_url' is required"));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
problems.push(format!(
|
||||
"provider name '{name}' must be a lowercase slug ([a-z0-9_-])"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
@@ -697,6 +1221,31 @@ mod tests {
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_token_prefers_explicit_binary() {
|
||||
// Interpreter launcher: without `detect`, the interpreter itself
|
||||
// would be probed (false "external" when node/python is on PATH).
|
||||
let a: AgentDef = serde_yaml::from_str(
|
||||
"name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
// No `detect`: falls back to the first token of run.
|
||||
let b: AgentDef =
|
||||
serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap();
|
||||
assert_eq!(b.detect_token().as_deref(), Some("python"));
|
||||
assert_eq!(b.first_token().as_deref(), Some("python"));
|
||||
// Plain binary command is unchanged.
|
||||
let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap();
|
||||
assert_eq!(c.detect_token().as_deref(), Some("claude"));
|
||||
// The embedded catalog must stay valid with the new field.
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap();
|
||||
assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai"));
|
||||
let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap();
|
||||
assert_eq!(nano.detect_token().as_deref(), Some("nanobot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_paths() {
|
||||
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
|
||||
@@ -764,6 +1313,30 @@ agents:
|
||||
assert!(problems.iter().any(|p| p.contains("'run' is required")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_the_user_theme_setting() {
|
||||
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
let overlay: Config = serde_yaml::from_str(
|
||||
r#"
|
||||
version: "1.0"
|
||||
settings:
|
||||
theme: ocean
|
||||
agents: []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
merge(&mut base, overlay);
|
||||
assert_eq!(base.settings.theme.as_deref(), Some("ocean"));
|
||||
// The embedded default catalog defines no theme: an overlay without
|
||||
// one leaves the merged setting untouched.
|
||||
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
let overlay: Config = serde_yaml::from_str("version: \"1.0\"
|
||||
agents: []
|
||||
").unwrap();
|
||||
merge(&mut base, overlay);
|
||||
assert_eq!(base.settings.theme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_or_many_parses_both() {
|
||||
let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap();
|
||||
|
||||
+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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user