Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f29baea5 | ||
|
|
cf53c98d98 | ||
|
|
991c79de50 | ||
|
|
d911ce5d92 | ||
|
|
fa2c42d505 | ||
|
|
e637c066fe |
+2
-2
@@ -230,7 +230,7 @@ settings:
|
|||||||
|
|
||||||
aliases:
|
aliases:
|
||||||
cc: claude-code
|
cc: claude-code
|
||||||
gemini: gemini-cli
|
gemini: antigravity-cli
|
||||||
|
|
||||||
groups:
|
groups:
|
||||||
dev: [claude-code, aider, codex]
|
dev: [claude-code, aider, codex]
|
||||||
@@ -506,7 +506,7 @@ Disponibles avant ou après la sous-commande.
|
|||||||
```yaml
|
```yaml
|
||||||
aliases:
|
aliases:
|
||||||
cc: claude-code
|
cc: claude-code
|
||||||
gemini: gemini-cli
|
gemini: antigravity-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
`am start cc` démarre `claude-code`.
|
`am start cc` démarre `claude-code`.
|
||||||
|
|||||||
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.1.3"
|
version = "1.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.1.4"
|
version = "1.1.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 🚀 agent-manager — vos agents IA, gérés comme des apps
|
# 🚀 agent-manager — vos agents IA, gérés comme des apps
|
||||||
|
|
||||||
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
|
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
|
||||||
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **77 agents connus**)
|
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **78 agents connus**)
|
||||||
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
|
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
|
||||||
Go, Rust…) prise en charge pour vous.
|
Go, Rust…) prise en charge pour vous.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
|
|||||||
|
|
||||||
## ✨ Pourquoi am ?
|
## ✨ Pourquoi am ?
|
||||||
|
|
||||||
- 🗂️ **Catalogue de 77 agents prêts à l'emploi** — descriptions, méthodes
|
- 🗂️ **Catalogue de 78 agents prêts à l'emploi** — descriptions, méthodes
|
||||||
d'installation, dépendances et commandes de démarrage déjà configurées.
|
d'installation, dépendances et commandes de démarrage déjà configurées.
|
||||||
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
|
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
|
||||||
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
|
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
|
||||||
@@ -158,7 +158,7 @@ Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AG
|
|||||||
|
|
||||||
#### Cycle d'installation (`am install <agent>`)
|
#### Cycle d'installation (`am install <agent>`)
|
||||||
|
|
||||||
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (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.
|
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (78 agents) avec `~/.config/agent-manager/config.yaml` et un éventuel `./agent-manager.yaml`. Vos définitions remplacent les entrées par défaut du même nom.
|
||||||
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place.
|
2. **Vérification des dépendances** — les outils requis (`node`, `python`, `uv`, `cargo`, `go`, `bun`, `git`...) sont détectés sur le `PATH`. S'il en manque, `am` détecte l'OS et propose la commande d'installation (`scoop`, `winget`, `apt`, `dnf`, `pacman`, `brew`...). Avec `--yes` ou `settings.auto_install_deps: true`, il peut l'exécuter à votre place.
|
||||||
3. **Choix de la méthode** — chaque agent déclare une méthode principale et des alternatives. `--method <nom>` force le choix ; sinon `am` prend la première disponible. Pour `am update`, c'est la méthode utilisée lors de l'installation initiale qui est reconstruite.
|
3. **Choix de la méthode** — chaque agent déclare une méthode principale et des alternatives. `--method <nom>` force le choix ; sinon `am` prend la première disponible. Pour `am update`, c'est la méthode utilisée lors de l'installation initiale qui est reconstruite.
|
||||||
4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
|
4. **Exécution de l'installateur** — selon le type, les commandes réelles lancées sont :
|
||||||
@@ -383,7 +383,7 @@ supprimez aussi ces emplacements.)
|
|||||||
1. --config <fichier>
|
1. --config <fichier>
|
||||||
2. ./agent-manager.yaml (répertoire courant)
|
2. ./agent-manager.yaml (répertoire courant)
|
||||||
3. ~/.config/agent-manager/config.yaml
|
3. ~/.config/agent-manager/config.yaml
|
||||||
4. catalogue embarqué (77 agents)
|
4. catalogue embarqué (78 agents)
|
||||||
|
|
||||||
Vos définitions **complètent ou surchargent** le catalogue par nom.
|
Vos définitions **complètent ou surchargent** le catalogue par nom.
|
||||||
|
|
||||||
|
|||||||
+161
-6
@@ -1,7 +1,13 @@
|
|||||||
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
||||||
|
|
||||||
> **Version du document : 3.0** · Statut : phases 0 à 3 livrées
|
> **Version du document : 3.1** · Statut : phases 0 à 3 livrées
|
||||||
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.0
|
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.5
|
||||||
|
>
|
||||||
|
> 🧭 **Bilan du 2026-08-23** : tout le périmètre versionné (J0 → J3, v0.7.0,
|
||||||
|
> épique v1.1.0) est livré — **0 issue ouverte** sur Gitea. Il reste :
|
||||||
|
> **1 bug prioritaire** (hang de `cargo test` / `am doctor`, §15.4),
|
||||||
|
> 3 fonctionnalités P1 jamais découpées en issues (§15.2), des activités de
|
||||||
|
> release (§15.3) et des idées neuves proposées (§16).
|
||||||
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
||||||
>
|
>
|
||||||
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
||||||
@@ -100,6 +106,55 @@
|
|||||||
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
||||||
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
||||||
> premier agent du catalogue à les déclarer.
|
> premier agent du catalogue à les déclarer.
|
||||||
|
>
|
||||||
|
> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec
|
||||||
|
> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown
|
||||||
|
> `agents/<name>.md` ≤ 0.29 est remplacé par une définition
|
||||||
|
> `functions/agents/<name>/index.yaml` + un registre `functions/agents.txt`).
|
||||||
|
> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats
|
||||||
|
> (compatibilité totale) et `am ai` détecte la définition nouvelle génération
|
||||||
|
> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte
|
||||||
|
> par défaut (dossier courant) est filtré par am (`.git`, `target`,
|
||||||
|
> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB)
|
||||||
|
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
|
||||||
|
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
|
||||||
|
> n'appelle plus l'API, il écho la requête).
|
||||||
|
>
|
||||||
|
> 🔧 **Maintenance v1.1.6 (2026-08-24)** : correctifs de robustesse —
|
||||||
|
> timeout de 30 s sur les commandes capturées (doctor/runner ne pendent plus
|
||||||
|
> si un agent ne répond pas, pipes vidés dans des threads pour éviter les
|
||||||
|
> deadlocks), `is_running` Windows via l'API Win32 (OpenProcess /
|
||||||
|
> GetExitCodeProcess, plus fiable que tasklist), rejet des chemins unsafe
|
||||||
|
> (path traversal) dans `am migrate --import`, frames WebSocket RFC 6455
|
||||||
|
> avec vraies longueurs > 125 octets, boucle de traduction i18n de la page
|
||||||
|
> web corrigée (plus de boucle infinie), index du trousseau (keyring)
|
||||||
|
> sérialisé en JSON (clés contenant des virgules).
|
||||||
|
>
|
||||||
|
> 🔧 **Maintenance v1.1.7 (2026-09-01)** : Gemini CLI a cessé de servir les
|
||||||
|
> comptes Google gratuits/Pro/Ultra le 2026-06-18 — remplacé par **Antigravity
|
||||||
|
> CLI** (`agy`, binaire Go fermé, même harnais qu'Antigravity 2.0). Nouveau
|
||||||
|
> catalogue : entrée `antigravity-cli` (release GitHub
|
||||||
|
> google-antigravity/antigravity-cli), `gemini-cli` marqué déprécié
|
||||||
|
> (`installable: false`, clé API payante seulement), alias `gemini`/`agy`
|
||||||
|
> redirigés, groupe `mainstream` et gabarit web mis à jour. Import des
|
||||||
|
> extensions/skills Gemini : `agy plugin import gemini`.
|
||||||
|
>
|
||||||
|
> 🔧 **Maintenance v1.1.8 (2026-09-01)** : providers **QwenCloud** ajoutés au
|
||||||
|
> registre par défaut — `qwen-cloud` (pay-as-you-go, Model Studio/DashScope,
|
||||||
|
> dashscope-intl…/compatible-mode/v1) et `qwen-cloud-token-plan` (abonnement
|
||||||
|
> Credits, token-plan.ap-southeast-1…/compatible-mode/v1, modèles restreints
|
||||||
|
> dont qwen3.8-max). `qwen-code` pointe sur qwen-cloud par défaut (env_map
|
||||||
|
> OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer
|
||||||
|
> sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via
|
||||||
|
> `am providers set-token`).
|
||||||
|
>
|
||||||
|
> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses
|
||||||
|
> providers dans une constante codée en dur — les QwenCloud ajoutés au
|
||||||
|
> registre n'y apparaissaient pas. La liste est maintenant construite depuis
|
||||||
|
> le registre fusionné (embarqué + config utilisateur), ollama et custom en
|
||||||
|
> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne
|
||||||
|
> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion
|
||||||
|
> `--yes`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,6 +171,8 @@
|
|||||||
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
|
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
|
||||||
10. [Risques & garde-fous](#10--risques--garde-fous)
|
10. [Risques & garde-fous](#10--risques--garde-fous)
|
||||||
11. [Par où commencer](#11--par-où-commencer)
|
11. [Par où commencer](#11--par-où-commencer)
|
||||||
|
12. [Bilan & reste à faire (2026-08-23)](#15--bilan--reste-à-faire-analyse-du-2026-08-23)
|
||||||
|
13. [Idées neuves (post-v1.1.5)](#16--idées-neuves-post-v115)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,8 +402,8 @@ consultable, reprenable, archivable.
|
|||||||
| Fonctionnalité | Effort | Phase |
|
| Fonctionnalité | Effort | Phase |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
|
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
|
||||||
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 |
|
| ⬜ am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) — **non livré, sans issue** | S | P1 |
|
||||||
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 |
|
| ⬜ am news — dernières releases des agents installés (API GitHub/Gitea, cache) — **non livré, sans issue** | M | P1 |
|
||||||
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
|
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
|
||||||
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
|
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
|
||||||
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
|
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
|
||||||
@@ -385,7 +442,7 @@ consultable, reprenable, archivable.
|
|||||||
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
|
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
|
||||||
| Notes : am note claude-code "utiliser pour …" | S | P1 |
|
| Notes : am note claude-code "utiliser pour …" | S | P1 |
|
||||||
| Tags personnels : am tag claude-code python | S | P1 |
|
| Tags personnels : am tag claude-code python | S | P1 |
|
||||||
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 |
|
| ⬜ Raccourcis de commandes : settings.shortcuts (i → install, s → start) — **non livré, sans issue** | S | P1 |
|
||||||
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
|
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
|
||||||
| Thèmes couleurs du REPL | S | P2 |
|
| Thèmes couleurs du REPL | S | P2 |
|
||||||
|
|
||||||
@@ -519,6 +576,102 @@ aichat -f . "résume les données de ces fichiers JSON"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 15. 🧭 Bilan & reste à faire (analyse du 2026-08-23)
|
||||||
|
|
||||||
|
> Analyse complète du dépôt le 2026-08-23 : code (v1.1.5, commit e637c06),
|
||||||
|
> historique git, tracker Gitea, artefacts de release.
|
||||||
|
|
||||||
|
### 15.1 ✅ Ce qui est livré
|
||||||
|
|
||||||
|
- **Tout le périmètre versionné** : J0/v0.3.0, J1/v0.4.1, J2/v0.6.0,
|
||||||
|
v0.7.0 (providers), J3/v1.0.0, épique v1.1.0 (onboarding/copilot) et les
|
||||||
|
maintenances v1.0.1 → v1.1.5.
|
||||||
|
- **0 issue ouverte** sur le tracker Gitea (issues #1 → #97 clôturées).
|
||||||
|
- `main` synchronisé avec `origin/main` ; Cargo.toml/Cargo.lock en
|
||||||
|
**v1.1.5** ; suite de tests : **455 tests passés, 0 échec** le
|
||||||
|
2026-08-23 (23 suites, hors `doctor_test` qui hang — §15.4).
|
||||||
|
- Vérifié présent dans le code : favoris/notes/tags (#39), dashboard,
|
||||||
|
monitor, web, serve, lab, sync, migrate, models, sandbox, registry,
|
||||||
|
ask, ai, setup + rôles copilot, self-update, services, schedule, i18n,
|
||||||
|
packaging, complétions, man pages.
|
||||||
|
|
||||||
|
### 15.2 ⬜ Fonctionnalités du roadmap NON livrées (jamais découpées en issues)
|
||||||
|
|
||||||
|
| Fonctionnalité | Axe | Effort | Constat dans le code |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `am compare a b` — tableau côte à côte | Axe 7 | S | aucune commande `Compare` dans `src/cli.rs` |
|
||||||
|
| `am news` — dernières releases des agents installés | Axe 7 | M | aucun module, 0 occurrence dans `src/` |
|
||||||
|
| `settings.shortcuts` — raccourcis REPL (i → install…) | Axe 10 | S | 0 occurrence de `shortcuts` dans `src/` |
|
||||||
|
| Extensions sœurs Axe 13 (`am summarize`, `am explain`, `am fix`, …) | Axe 13 | M | absentes — voir §13.5 et l'idée N10 (§16) |
|
||||||
|
|
||||||
|
➡️ Suggestion : créer 3 issues Gitea (compare, news, shortcuts) en P1 —
|
||||||
|
petits efforts à forte valeur, seuls restes des jalons « cockpit ».
|
||||||
|
|
||||||
|
### 15.3 📦 Activités de release / opérations
|
||||||
|
|
||||||
|
| Activité | Détail |
|
||||||
|
|---|---|
|
||||||
|
| **Publier la release v1.1.5** | Le commit `e637c06` (fix aichat ≥ 0.30) est sur `main` **sans tag ni release Gitea** → installateurs et `am self-update` servent encore la v1.1.4. Taguer `v1.1.5` pour déclencher `.gitea/workflows/release.yml`. |
|
||||||
|
| **Compléter les assets v1.1.4** | La release v1.1.4 publiée ne contient **que** `am-windows-x86_64.zip` : binaires Linux (x86_64/aarch64) et macOS absents → les installateurs Linux/macOS retombent sur `cargo install --git`. |
|
||||||
|
| **Artéfacts `dist/` obsolètes** | Les archives de `dist/` datent de l'ère v0.6.0 (2026-08-19) ; laisser la CI régénérer celles de v1.1.5. |
|
||||||
|
| **Stash obsolète** | `stash@{0}` « WIP issue #39 : favoris, notes, tags » (2026-08-17 09:12) — la fonctionnalité a été livrée autrement dans v0.4.1 (champs présents dans `src/state.rs`) ; à purger (`git stash drop`) après confirmation. |
|
||||||
|
|
||||||
|
### 15.4 🐛 Bug découvert pendant l'analyse (2026-08-23) — à corriger en priorité
|
||||||
|
|
||||||
|
**`cargo test` se suspend** sur `doctor_test::report_contains_core_checks`
|
||||||
|
sur cette machine (reproduit 3 fois, dont une exécution > 20 min), et le
|
||||||
|
même hang peut frapper l'utilisateur final via `am doctor`.
|
||||||
|
|
||||||
|
- **Cause racine** : `SystemRunner::run` (`src/runner.rs`, branche
|
||||||
|
`capture`) attend `Command::output()` **sans timeout** et sans
|
||||||
|
protection contre l'héritage des pipes. `am doctor` sonde les outils du
|
||||||
|
PATH (`ollama ls`, `llama-server --version`, `git --version`…) ; quand
|
||||||
|
le processus fils engendre un petit-enfant qui conserve les handles
|
||||||
|
stdout/stderr (cas réel observé : `conhost.exe` alloué pour
|
||||||
|
`ollama.exe ls` lancé sans console), `output()` attend l'EOF des pipes
|
||||||
|
**indéfiniment**.
|
||||||
|
- **Reproduction** : `target\debug\deps\doctor_test-*.exe --nocapture
|
||||||
|
report_contains_core_checks` → hang > 40 s avec enfants `ollama.exe ls`
|
||||||
|
+ `conhost.exe` sous le processus de test ; l'ancien binaire de test du
|
||||||
|
2026-08-21 passait en 2,2 s (déclenchement du conhost non déterministe).
|
||||||
|
- **Périmètre touché** : tout ce qui passe par `SystemRunner::run` avec
|
||||||
|
capture — `am doctor` donc, et les sondes d'installation. (`am models`,
|
||||||
|
`am lab`, les plugins et la sonde externe utilisent déjà `wait_timeout`
|
||||||
|
et sont immunisés.)
|
||||||
|
- **Correctif proposé** : aligner `SystemRunner::run` sur le pattern déjà
|
||||||
|
éprouvé dans `src/models.rs` / `src/lab.rs` / `src/plugins.rs` : spawn
|
||||||
|
+ `wait_timeout` (30 s par défaut, `settings.probe_timeout_secs`) +
|
||||||
|
drain des pipes avant l'attente + kill de l'arbre à l'expiration — les
|
||||||
|
commentaires de `lab.rs` et `plugins.rs` documentent exactement ce
|
||||||
|
deadlock. À compléter par un test de régression (faux binaire qui
|
||||||
|
engendre un enfant garde-fou).
|
||||||
|
- **Contournement immédiat** : lancer les suites une à une, sans
|
||||||
|
`--test doctor_test`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 💡 Idées neuves (post-v1.1.5)
|
||||||
|
|
||||||
|
> Ajoutées lors de l'analyse du 2026-08-23. Toutes ⬜ (proposées), non
|
||||||
|
> planifiées. Respectent l'ADN du projet : zéro dépendance runtime, local
|
||||||
|
> par défaut, contrats `--json` stables.
|
||||||
|
|
||||||
|
| # | Idée | Description | Axe | Effort | Phase |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| N1 | ⬜ **`am mcp` — serveur MCP** | Exposer am comme serveur *Model Context Protocol* : les agents (Claude Code, Codex, aichat…) découvrent et pilotent le parc via des outils MCP (`am__list`, `am__stats`, `am__sessions`, `am__start`…). am devient le hub de coopération entre agents. | nouveau | L | P3 |
|
||||||
|
| N2 | ⬜ **Budgets & alertes de coûts** | Budget par agent/projet avec alertes à seuils (80 % / 100 %) et blocage doux, branché sur le suivi des coûts (#49). | Axe 1 | M | P2 |
|
||||||
|
| N3 | ⬜ **Webhooks de notification** | Alertes `am watch` / `schedule` / `doctor --watch` vers un webhook (Discord, Slack, n8n, Gotify) en plus du `--notify` local. | Axe 6 | S | P2 |
|
||||||
|
| N4 | ⬜ **`am test <agent>` — smoke test** | Vérification normalisée post-install/post-update : le binaire répond, ping provider, tâche minimale — généralise le ping de `am setup` à tout le catalogue. | Axe 7 | M | P2 |
|
||||||
|
| N5 | ⬜ **`am diff` — comparaison de configs** | Diff de config/état entre deux machines ou deux points de sync ; complète `am sync` (#66) et `am migrate` (#68) pour le multi-machine. | Axe 9 | S | P2 |
|
||||||
|
| N6 | ⬜ **Transcripts de session** | `am sessions transcript <id>` : export Markdown lisible et partageable d'une session (commandes + sorties), complément de `sessions export` (#53). | Axe 4 | S | P2 |
|
||||||
|
| N7 | ⬜ **`am doctor --report`** | Rapport de diagnostic complet anonymisé (versions, doctor, événements récents) prêt à joindre à un bug report. | Axe 12 | S | P2 |
|
||||||
|
| N8 | ⬜ **Catalogue air-gapped** | `am catalog snapshot` : instantané hors-ligne complet du catalogue distant (définitions + binaires) pour machines sans internet. | Axe 7 | M | P3 |
|
||||||
|
| N9 | ⬜ **Export OpenTelemetry** | Export opt-in du journal `events.jsonl` vers OTLP/Grafana pour les équipes qui ont déjà une pile d'observabilité. | Axe 1 | M | P3 |
|
||||||
|
| N10 | ⬜ **Épique « Boîte à outils IA »** | Formaliser les commandes sœurs §13.5 sur les rôles copilot aichat (v1.1.0) : `am commit`, `am review`, `am explain`, `am fix`, `am summarize`, `am doc`, `am translate`, `am ask-code`, `am search-web`, `am note`. Une commande = un rôle + un raccourci de prompt, sécurité `--exec` héritée de `am ai`. | Axe 13 | L | P2 → P3 |
|
||||||
|
| N11 | ⬜ **Suite de tests 100 % hermétique** | `update_policy_test` (> 60 s/test) appelle réellement l'API GitHub via `installers::latest_version` ; prévoir un mode hors-ligne des sondes de version (flag test/offline ou mock), pour une suite rapide et déterministe partout. | Axe 12 | S | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 8. 🚀 Jalons versionnés
|
## 8. 🚀 Jalons versionnés
|
||||||
|
|
||||||
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|
| Jalon | Version | Contenu principal | Durée | Critère de sortie |
|
||||||
@@ -697,4 +850,6 @@ 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
|
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
|
(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),
|
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
|
||||||
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe).*
|
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe),
|
||||||
|
> v1.1.0 (onboarding/copilot) → v1.1.5 (aichat ≥ 0.30). Bilan & idées
|
||||||
|
> neuves ajoutés le 2026-08-23 (§15, §16).*
|
||||||
|
|||||||
+50
-5
@@ -108,6 +108,20 @@ settings:
|
|||||||
base_url: https://generativelanguage.googleapis.com/v1beta
|
base_url: https://generativelanguage.googleapis.com/v1beta
|
||||||
default_model: gemini-2.5-pro
|
default_model: gemini-2.5-pro
|
||||||
models: [gemini-2.5-pro, gemini-2.5-flash]
|
models: [gemini-2.5-pro, gemini-2.5-flash]
|
||||||
|
# QwenCloud (Alibaba) — deux modes de facturation, deux providers :
|
||||||
|
# - qwen-cloud-token-plan : abonnement Credits (Personal/Team), clé
|
||||||
|
# dédiée Token Plan, modèles restreints (voir docs.qwencloud.com).
|
||||||
|
# Base URL internationale (ap-southeast-1) ; la variante Pékin
|
||||||
|
# token-plan.cn-beijing.maas.aliyuncs.com sert la Chine.
|
||||||
|
# - qwen-cloud : pay-as-you-go (clé sk- Model Studio / DashScope).
|
||||||
|
qwen-cloud-token-plan:
|
||||||
|
base_url: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
|
||||||
|
default_model: qwen3.8-max
|
||||||
|
models: [qwen3.8-max, qwen3.8-flash, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, deepseek-v4-pro, glm-5.2]
|
||||||
|
qwen-cloud:
|
||||||
|
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
|
||||||
|
default_model: qwen3.7-plus
|
||||||
|
models: [qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, qwen3-coder-plus, qwen3-coder-next, deepseek-v4-pro]
|
||||||
openrouter:
|
openrouter:
|
||||||
base_url: https://openrouter.ai/api/v1
|
base_url: https://openrouter.ai/api/v1
|
||||||
default_model: deepseek/deepseek-chat-v3-0324
|
default_model: deepseek/deepseek-chat-v3-0324
|
||||||
@@ -122,7 +136,10 @@ settings:
|
|||||||
# --- Command aliases ----------------------------------------------------------
|
# --- Command aliases ----------------------------------------------------------
|
||||||
aliases:
|
aliases:
|
||||||
cc: claude-code
|
cc: claude-code
|
||||||
gemini: gemini-cli
|
# Gemini CLI (npm @google/gemini-cli) ne sert plus les comptes Google
|
||||||
|
# gratuits/Pro/Ultra depuis le 2026-06-18 — remplacé par Antigravity CLI.
|
||||||
|
gemini: antigravity-cli
|
||||||
|
agy: antigravity-cli
|
||||||
gh-copilot: github-copilot
|
gh-copilot: github-copilot
|
||||||
q: amazon-q
|
q: amazon-q
|
||||||
droid: factory-droid
|
droid: factory-droid
|
||||||
@@ -136,7 +153,7 @@ groups:
|
|||||||
dev: [claude-code, aider, codex]
|
dev: [claude-code, aider, codex]
|
||||||
local: [atomic-agent, nanocoder, gptme, goose]
|
local: [atomic-agent, nanocoder, gptme, goose]
|
||||||
claw: [openclaw, zeroclaw, picoclaw]
|
claw: [openclaw, zeroclaw, picoclaw]
|
||||||
mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code]
|
mainstream: [claude-code, opencode, antigravity-cli, codex, qwen-code]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Agents
|
# Agents
|
||||||
@@ -290,9 +307,26 @@ agents:
|
|||||||
note: "projet en mode musée — maintenance 100 % automatisée"
|
note: "projet en mode musée — maintenance 100 % automatisée"
|
||||||
tags: [rust, clean-room, fork]
|
tags: [rust, clean-room, fork]
|
||||||
|
|
||||||
|
- name: antigravity-cli
|
||||||
|
display_name: "Antigravity CLI"
|
||||||
|
description: "Agent terminal officiel de Google (successseur de Gemini CLI, fermé, binaire Go). Même harnais d'agents qu'Antigravity 2.0 : multi-agents asynchrones, skills, hooks, plugins, MCP. Auth Google via keyring système, quota partagé avec l'app desktop (surveiller /usage)."
|
||||||
|
category: coding-agent
|
||||||
|
website: https://antigravity.google/cli
|
||||||
|
install:
|
||||||
|
type: binary
|
||||||
|
repo: google-antigravity/antigravity-cli
|
||||||
|
binary: agy
|
||||||
|
run: agy
|
||||||
|
provider: google
|
||||||
|
setup_hints:
|
||||||
|
- "agy (premier lancement : setup interactif + auth Google via le trousseau)"
|
||||||
|
- "agy plugin import gemini (importe extensions/skills de l'ancien Gemini CLI)"
|
||||||
|
tags: [google, mainstream, antigravity]
|
||||||
|
note: "Remplace Gemini CLI : le 2026-06-18, Gemini CLI a cessé de servir les comptes Google gratuits/Pro/Ultra. Import des extensions/skills Gemini : 'agy plugin import gemini' ; GEMINI.md et ~/.gemini/ restent lus (config native : ~/.gemini/antigravity-cli/, skills workspace : .agents/skills/, MCP : mcp_config.json avec clé 'serverUrl')."
|
||||||
|
|
||||||
- name: gemini-cli
|
- name: gemini-cli
|
||||||
display_name: "Gemini CLI"
|
display_name: "Gemini CLI (déprécié)"
|
||||||
description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche."
|
description: "Ancien agent terminal Google (Apache-2.0). Ne sert plus les comptes Google gratuits/Pro/Ultra depuis le 2026-06-18 ; reste utilisable avec une clé API Gemini payante. Migrer vers antigravity-cli."
|
||||||
category: coding-agent
|
category: coding-agent
|
||||||
website: https://github.com/google-gemini/gemini-cli
|
website: https://github.com/google-gemini/gemini-cli
|
||||||
install:
|
install:
|
||||||
@@ -301,7 +335,9 @@ agents:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||||
run: gemini
|
run: gemini
|
||||||
tags: [google, mainstream]
|
installable: false
|
||||||
|
tags: [google, deprecated]
|
||||||
|
note: "déprécié — remplacé par Antigravity CLI ('am install antigravity-cli'), voir la note de cette entrée"
|
||||||
|
|
||||||
- name: codex
|
- name: codex
|
||||||
display_name: "Codex CLI"
|
display_name: "Codex CLI"
|
||||||
@@ -435,6 +471,15 @@ agents:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||||
run: qwen-code
|
run: qwen-code
|
||||||
|
provider: qwen-cloud
|
||||||
|
config:
|
||||||
|
env_map:
|
||||||
|
api_key: OPENAI_API_KEY
|
||||||
|
model: OPENAI_MODEL
|
||||||
|
base_url: OPENAI_BASE_URL
|
||||||
|
setup_hints:
|
||||||
|
- "am install qwen-code --provider qwen-cloud-token-plan (abonnement Credits Token Plan au lieu du pay-as-you-go)"
|
||||||
|
- "am providers set-token qwen-cloud-token-plan (clé dédiée Token Plan ; pay-as-you-go : clé sk- Model Studio)"
|
||||||
tags: [alibaba, qwen]
|
tags: [alibaba, qwen]
|
||||||
|
|
||||||
- name: grok-build
|
- name: grok-build
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.ie \n(.g .ds Aq \(aq
|
.ie \n(.g .ds Aq \(aq
|
||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am 1 "am 1.1.0"
|
.TH am 1 "am 1.1.9"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
am \- agent\-manager (am) — manage local AI coding agents
|
am \- agent\-manager (am) — manage local AI coding agents
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -250,4 +250,4 @@ Export the configuration and installation state (backup)
|
|||||||
am\-import(1)
|
am\-import(1)
|
||||||
Import a previously exported configuration and state
|
Import a previously exported configuration and state
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
v1.1.0
|
v1.1.9
|
||||||
|
|||||||
+108
-13
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
||||||
//! context files (default: the current directory). Exec mode generates the
|
//! context files (default: the current directory). Exec mode generates the
|
||||||
//! shell command through aichat in dry-run, classifies it, applies the
|
//! shell command through aichat (`--code` — never executed by aichat),
|
||||||
//! safety policy (settings.shell_ai, dry-run by default) and only executes
|
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
|
||||||
//! after confirmation when required.
|
//! default) and only executes after confirmation when required.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::AiArgs;
|
use crate::cli::AiArgs;
|
||||||
@@ -45,9 +45,11 @@ pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
|||||||
|
|
||||||
let prompt = args.prompt.join(" ");
|
let prompt = args.prompt.join(" ");
|
||||||
// Context files: explicit --files wins, else the current directory
|
// Context files: explicit --files wins, else the current directory
|
||||||
// (issue #96: `aichat -f . "<prompt>"`).
|
// (issue #96: `aichat -f . "<prompt>"`). The default directory is
|
||||||
|
// walked by am to skip noise/vendored/binary files — aichat itself
|
||||||
|
// filters nothing and fails on non-UTF-8 content (e.g. .git/index).
|
||||||
let files: Vec<String> = if args.files.is_empty() {
|
let files: Vec<String> = if args.files.is_empty() {
|
||||||
vec![".".to_string()]
|
context_files(std::path::Path::new("."))
|
||||||
} else {
|
} else {
|
||||||
args.files
|
args.files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -75,23 +77,92 @@ fn aichat_installed(app: &App) -> bool {
|
|||||||
|| which::which("aichat").is_ok()
|
|| which::which("aichat").is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk `dir` and collect text files usable as aichat context (the
|
||||||
|
/// default `--files` when the flag is absent). aichat filters nothing
|
||||||
|
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
|
||||||
|
/// - skipped directories: `.git`, vendored/build noise (`target`,
|
||||||
|
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
|
||||||
|
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
|
||||||
|
/// Then the survivors are sorted and taken while a total budget of
|
||||||
|
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
|
||||||
|
/// at 40 files. Empty when nothing qualifies (aichat is called without
|
||||||
|
/// `-f`).
|
||||||
|
fn context_files(dir: &std::path::Path) -> Vec<String> {
|
||||||
|
const MAX_FILES: usize = 40;
|
||||||
|
const MAX_SIZE: u64 = 64 * 1024;
|
||||||
|
const TOTAL_BUDGET: u64 = 128 * 1024;
|
||||||
|
const SKIP_DIRS: &[&str] = &[
|
||||||
|
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
|
||||||
|
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
|
||||||
|
".cargo", "obj", "out", ".svn", ".hg",
|
||||||
|
];
|
||||||
|
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
|
||||||
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if path.is_dir() {
|
||||||
|
if SKIP_DIRS.contains(&name.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walk(&path, out);
|
||||||
|
} else {
|
||||||
|
if name.starts_with(".env") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(meta) = entry.metadata() else { continue };
|
||||||
|
if meta.len() > MAX_SIZE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// UTF-8 sniff on the first bytes — aichat rejects binaries.
|
||||||
|
let Ok(mut f) = std::fs::File::open(&path) else { continue };
|
||||||
|
use std::io::Read;
|
||||||
|
let mut head = Vec::new();
|
||||||
|
if f.take(8192).read_to_end(&mut head).is_err()
|
||||||
|
|| std::str::from_utf8(&head).is_err()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push((path.display().to_string(), meta.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut found = Vec::new();
|
||||||
|
walk(dir, &mut found);
|
||||||
|
found.sort();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
for (p, len) in found {
|
||||||
|
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total += len;
|
||||||
|
files.push(p);
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the aichat `--agent` arguments for the requested role. Without a
|
/// 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
|
/// `--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
|
/// definition is missing the roles are regenerated on the fly, and a
|
||||||
/// failure degrades gracefully (no role). An explicit unknown role is an
|
/// generation failure degrades gracefully (no role). An explicit unknown
|
||||||
/// error.
|
/// role is an error.
|
||||||
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
||||||
let role = role.unwrap_or("am-copilot");
|
let role = role.unwrap_or("am-copilot");
|
||||||
let dir = crate::roles::aichat_agents_dir();
|
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
|
||||||
let path = dir.join(format!("{role}.md"));
|
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
|
||||||
if path.exists() {
|
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
|
||||||
|
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
|
||||||
|
let present = || def.exists() || legacy.exists();
|
||||||
|
if present() {
|
||||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||||
}
|
}
|
||||||
if role == "am-copilot" {
|
if role == "am-copilot" {
|
||||||
if let Err(e) = crate::roles::generate_all(app) {
|
if let Err(e) = crate::roles::generate_all(app) {
|
||||||
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
||||||
}
|
}
|
||||||
if path.exists() {
|
if present() {
|
||||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||||
}
|
}
|
||||||
app.log.warn(crate::i18n::tr_in(
|
app.log.warn(crate::i18n::tr_in(
|
||||||
@@ -171,7 +242,9 @@ fn exec_mode(
|
|||||||
"would generate & classify via aichat (exec) — commande non exécutée"
|
"would generate & classify via aichat (exec) — commande non exécutée"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let role = role_args(app, args.role.as_deref())?;
|
// Exec mode asks for the raw command: default to the am-do role
|
||||||
|
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
|
||||||
|
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
|
||||||
let cmd = crate::shell_ai::generate(
|
let cmd = crate::shell_ai::generate(
|
||||||
app,
|
app,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -256,4 +329,26 @@ mod tests {
|
|||||||
let s = crate::config::ShellAiSettings::default();
|
let s = crate::config::ShellAiSettings::default();
|
||||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_files_skips_binary_noise_and_secrets() {
|
||||||
|
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(base.join(".git")).unwrap();
|
||||||
|
std::fs::create_dir_all(base.join("target")).unwrap();
|
||||||
|
std::fs::create_dir_all(base.join("src")).unwrap();
|
||||||
|
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
|
||||||
|
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
|
||||||
|
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
|
||||||
|
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
|
||||||
|
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
|
||||||
|
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
|
||||||
|
let files = context_files(&base);
|
||||||
|
let names: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,11 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
|
|||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
dir.join(crate::config::CONFIG_FILE_NAME)
|
dir.join(crate::config::CONFIG_FILE_NAME)
|
||||||
} else {
|
} else {
|
||||||
state_dir.join(rel)
|
let t = state_dir.join(rel);
|
||||||
|
if !t.starts_with(&state_dir) {
|
||||||
|
anyhow::bail!("security error: bundle contains unsafe path '{}'", bf.path);
|
||||||
|
}
|
||||||
|
t
|
||||||
};
|
};
|
||||||
if let Some(parent) = target.parent() {
|
if let Some(parent) = target.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
|
|||||||
+1
-1
@@ -899,7 +899,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
name: "ai",
|
name: "ai",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
usage: "ai {flags} <prompt...>",
|
usage: "ai {flags} <prompt...>",
|
||||||
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
|
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --code — jamais exécutée par aichat), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
|
||||||
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
|
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
|
||||||
flags: &[
|
flags: &[
|
||||||
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
|
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ pub fn sh(
|
|||||||
env: &BTreeMap<String, String>,
|
env: &BTreeMap<String, String>,
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
) -> Result<RunOutput> {
|
) -> Result<RunOutput> {
|
||||||
let out = runner.run(cmd, args, env, cwd, true)?;
|
// Installs are legitimately long (npm/cargo/go/... can take minutes):
|
||||||
|
// no probe timeout here, only run() (doctor/version probes) is bounded.
|
||||||
|
let out = runner.run_untimed(cmd, args, env, cwd, true)?;
|
||||||
if out.code != 0 {
|
if out.code != 0 {
|
||||||
let mut detail = out.stderr.trim().to_string();
|
let mut detail = out.stderr.trim().to_string();
|
||||||
if detail.is_empty() {
|
if detail.is_empty() {
|
||||||
|
|||||||
+23
-14
@@ -30,7 +30,6 @@ impl ExternalInfo {
|
|||||||
/// Detect the version of an external binary (--version), bounded by a
|
/// Detect the version of an external binary (--version), bounded by a
|
||||||
/// timeout so a misbehaving agent can never hang the whole listing.
|
/// timeout so a misbehaving agent can never hang the whole listing.
|
||||||
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||||
use std::io::Read;
|
|
||||||
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
|
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
|
||||||
let mut cmd = std::process::Command::new(&prog);
|
let mut cmd = std::process::Command::new(&prog);
|
||||||
cmd.args(&prefix)
|
cmd.args(&prefix)
|
||||||
@@ -41,6 +40,24 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
|||||||
let Ok(mut child) = cmd.spawn() else {
|
let Ok(mut child) = cmd.spawn() else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
let stdout_pipe = child.stdout.take();
|
||||||
|
let stderr_pipe = child.stderr.take();
|
||||||
|
|
||||||
|
let stdout_handle = std::thread::spawn(move || {
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(mut pipe) = stdout_pipe {
|
||||||
|
let _ = std::io::Read::read_to_string(&mut pipe, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
});
|
||||||
|
let stderr_handle = std::thread::spawn(move || {
|
||||||
|
let mut err = String::new();
|
||||||
|
if let Some(mut pipe) = stderr_pipe {
|
||||||
|
let _ = std::io::Read::read_to_string(&mut pipe, &mut err);
|
||||||
|
}
|
||||||
|
err
|
||||||
|
});
|
||||||
|
|
||||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
||||||
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
|
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
|
||||||
Ok(Some(s)) => Some(s),
|
Ok(Some(s)) => Some(s),
|
||||||
@@ -51,22 +68,14 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
let Some(status) = status else {
|
let stdout = stdout_handle.join().unwrap_or_default();
|
||||||
return None;
|
let stderr = stderr_handle.join().unwrap_or_default();
|
||||||
};
|
|
||||||
|
let status = status?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut stdout = String::new();
|
let combined = format!("{stdout}\n{stderr}");
|
||||||
let mut stderr = String::new();
|
|
||||||
if let Some(mut o) = child.stdout.take() {
|
|
||||||
let _ = o.read_to_string(&mut stdout);
|
|
||||||
}
|
|
||||||
if let Some(mut e) = child.stderr.take() {
|
|
||||||
let _ = e.read_to_string(&mut stderr);
|
|
||||||
}
|
|
||||||
let combined = format!("{stdout}
|
|
||||||
{stderr}");
|
|
||||||
crate::version::find_version(&combined)
|
crate::version::find_version(&combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -17,14 +17,19 @@ pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
|
|||||||
pub fn is_running(pid: u32) -> bool {
|
pub fn is_running(pid: u32) -> bool {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let out = Command::new("tasklist")
|
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, STILL_ACTIVE};
|
||||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
use windows_sys::Win32::System::Threading::{
|
||||||
.output();
|
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
match out {
|
};
|
||||||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
unsafe {
|
||||||
.split_whitespace()
|
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||||
.any(|w| w == pid.to_string()),
|
if handle == 0 {
|
||||||
Err(_) => false,
|
return false;
|
||||||
|
}
|
||||||
|
let mut code: u32 = 0;
|
||||||
|
let ok = GetExitCodeProcess(handle, &mut code);
|
||||||
|
CloseHandle(handle);
|
||||||
|
ok != 0 && code == STILL_ACTIVE as u32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
|
|||||||
+120
-23
@@ -1,12 +1,16 @@
|
|||||||
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
||||||
//!
|
//!
|
||||||
//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or
|
//! Generates aichat agent definitions that turn `am ai` into a
|
||||||
//! `%APPDATA%\aichat\agents\` on Windows) that turn `am ai` into a
|
|
||||||
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
|
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
|
||||||
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
|
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
|
||||||
//! by default, English supported) and instructs the model to use the
|
//! by default, English supported) and instructs the model to use the
|
||||||
//! stable `--json` contracts of `am` to inspect the environment.
|
//! stable `--json` contracts of `am` to inspect the environment.
|
||||||
//!
|
//!
|
||||||
|
//! Two layouts are written for compatibility with every aichat version:
|
||||||
|
//! - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||||
|
//! + `functions/agents.txt` (registry), under the aichat config dir;
|
||||||
|
//! - aichat <= 0.29 : legacy markdown agents `agents/<name>.md`.
|
||||||
|
//!
|
||||||
//! The aichat config file is only created when it does not exist yet —
|
//! The aichat config file is only created when it does not exist yet —
|
||||||
//! an existing configuration is never touched (respect of the user setup).
|
//! an existing configuration is never touched (respect of the user setup).
|
||||||
|
|
||||||
@@ -24,20 +28,20 @@ pub const ROLE_NAMES: &[&str] = &[
|
|||||||
"am-orchestrator",
|
"am-orchestrator",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Directory where aichat stores its agent definitions. Candidates are
|
/// Base config directory of aichat. Candidates are probed in order: an
|
||||||
/// probed in order: an existing aichat config wins, otherwise the platform
|
/// existing aichat config wins, otherwise the platform default (XDG on
|
||||||
/// default (XDG on unix, %APPDATA% on Windows).
|
/// unix, %APPDATA% on Windows).
|
||||||
pub fn aichat_agents_dir() -> PathBuf {
|
pub fn aichat_config_dir() -> PathBuf {
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
candidates.push(PathBuf::from(xdg).join("aichat").join("agents"));
|
candidates.push(PathBuf::from(xdg).join("aichat"));
|
||||||
}
|
}
|
||||||
if let Some(home) = crate::config::home_dir() {
|
if let Some(home) = crate::config::home_dir() {
|
||||||
candidates.push(home.join(".config").join("aichat").join("agents"));
|
candidates.push(home.join(".config").join("aichat"));
|
||||||
}
|
}
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
candidates.push(PathBuf::from(appdata).join("aichat").join("agents"));
|
candidates.push(PathBuf::from(appdata).join("aichat"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c in &candidates {
|
for c in &candidates {
|
||||||
@@ -48,19 +52,38 @@ pub fn aichat_agents_dir() -> PathBuf {
|
|||||||
// No existing aichat config: platform default.
|
// No existing aichat config: platform default.
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
return PathBuf::from(appdata).join("aichat").join("agents");
|
return PathBuf::from(appdata).join("aichat");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::config::home_dir()
|
crate::config::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("aichat")
|
.join("aichat")
|
||||||
.join("agents")
|
}
|
||||||
|
|
||||||
|
/// Legacy agents directory (aichat <= 0.29): `agents/<name>.md` files
|
||||||
|
/// with frontmatter. aichat >= 0.30 reads the new definitions instead
|
||||||
|
/// (see [`aichat_agent_def_dir`]); the .md files are still written so
|
||||||
|
/// older aichat versions keep working.
|
||||||
|
pub fn aichat_agents_dir() -> PathBuf {
|
||||||
|
aichat_config_dir().join("agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Functions directory (aichat >= 0.30): agent definitions live in
|
||||||
|
/// `functions/agents/<name>/index.yaml` and the agent registry in
|
||||||
|
/// `functions/agents.txt`.
|
||||||
|
pub fn aichat_functions_dir() -> PathBuf {
|
||||||
|
aichat_config_dir().join("functions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory of one agent definition (aichat >= 0.30).
|
||||||
|
pub fn aichat_agent_def_dir(name: &str) -> PathBuf {
|
||||||
|
aichat_functions_dir().join("agents").join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the aichat configuration file (created only if absent).
|
/// Path of the aichat configuration file (created only if absent).
|
||||||
pub fn aichat_config_path() -> PathBuf {
|
pub fn aichat_config_path() -> PathBuf {
|
||||||
aichat_agents_dir().parent().unwrap().join("config.yaml")
|
aichat_config_dir().join("config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list of `am` commands, embedded in the copilot prompt so the model
|
/// The list of `am` commands, embedded in the copilot prompt so the model
|
||||||
@@ -99,8 +122,8 @@ fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
|
|||||||
/// The bilingual instruction footer shared by every role.
|
/// The bilingual instruction footer shared by every role.
|
||||||
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
|
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
|
||||||
|
|
||||||
/// Build the markdown content of one role.
|
/// The (description, tools, body) of one role.
|
||||||
pub fn role_content(name: &str) -> Option<String> {
|
fn role_parts(name: &str) -> Option<(&'static str, &'static [&'static str], String)> {
|
||||||
let (desc, tools, body) = match name {
|
let (desc, tools, body) = match name {
|
||||||
"am-copilot" => (
|
"am-copilot" => (
|
||||||
"Copilot de agent-manager : guide, catalogue, dépannage",
|
"Copilot de agent-manager : guide, catalogue, dépannage",
|
||||||
@@ -181,25 +204,76 @@ pub fn role_content(name: &str) -> Option<String> {
|
|||||||
),
|
),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let content = format!(
|
Some((desc, tools, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The aichat >= 0.30 agent definition (`index.yaml`): the subset of
|
||||||
|
/// aichat's AgentDefinition that am generates.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct AgentDefinition {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
version: String,
|
||||||
|
instructions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the legacy markdown content of one role (aichat <= 0.29).
|
||||||
|
pub fn role_content(name: &str) -> Option<String> {
|
||||||
|
let (desc, tools, body) = role_parts(name)?;
|
||||||
|
Some(format!(
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
frontmatter(name, desc, tools),
|
frontmatter(name, desc, tools),
|
||||||
body,
|
body,
|
||||||
LANG_FOOTER
|
LANG_FOOTER
|
||||||
);
|
))
|
||||||
Some(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write every am-* role into the aichat agents directory (creates the
|
/// The aichat >= 0.30 `index.yaml` definition of one role.
|
||||||
/// directory when missing). Existing files with the same name are
|
pub fn role_definition(name: &str) -> Option<String> {
|
||||||
/// overwritten — they are generated artifacts of am.
|
let (desc, _tools, body) = role_parts(name)?;
|
||||||
|
let def = AgentDefinition {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: desc.to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
instructions: format!("{body}{LANG_FOOTER}"),
|
||||||
|
};
|
||||||
|
serde_yaml::to_string(&def).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write every am-* role into the aichat agent stores (creates the
|
||||||
|
/// directories when missing). Two layouts are written so any aichat
|
||||||
|
/// version works:
|
||||||
|
/// - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||||
|
/// + `functions/agents.txt` (registry);
|
||||||
|
/// - aichat <= 0.29 : legacy markdown `agents/<name>.md`.
|
||||||
|
/// Existing files with the same name are overwritten — they are generated
|
||||||
|
/// artifacts of am.
|
||||||
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
||||||
let dir = aichat_agents_dir();
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
|
||||||
let mut written = Vec::new();
|
let mut written = Vec::new();
|
||||||
|
|
||||||
|
// New layout (aichat >= 0.30): index.yaml definitions + agents.txt.
|
||||||
|
let funcs_dir = aichat_functions_dir();
|
||||||
|
let agents_dir = funcs_dir.join("agents");
|
||||||
|
std::fs::create_dir_all(&agents_dir)?;
|
||||||
|
for name in ROLE_NAMES {
|
||||||
|
let def = role_definition(name).expect("known role");
|
||||||
|
let def_path = agents_dir.join(name).join("index.yaml");
|
||||||
|
std::fs::create_dir_all(def_path.parent().expect("parent dir"))?;
|
||||||
|
std::fs::write(&def_path, def)?;
|
||||||
|
written.push(def_path);
|
||||||
|
}
|
||||||
|
let list_path = funcs_dir.join("agents.txt");
|
||||||
|
let mut list = String::from("# generated by `am setup` — aichat >= 0.30 agent registry\n");
|
||||||
|
list.push_str(&format!("{}\n", ROLE_NAMES.join("\n")));
|
||||||
|
std::fs::write(&list_path, list)?;
|
||||||
|
written.push(list_path);
|
||||||
|
|
||||||
|
// Legacy layout (aichat <= 0.29): markdown agents in agents/.
|
||||||
|
let legacy_dir = aichat_agents_dir();
|
||||||
|
std::fs::create_dir_all(&legacy_dir)?;
|
||||||
for name in ROLE_NAMES {
|
for name in ROLE_NAMES {
|
||||||
let content = role_content(name).expect("known role");
|
let content = role_content(name).expect("known role");
|
||||||
let path = dir.join(format!("{name}.md"));
|
let path = legacy_dir.join(format!("{name}.md"));
|
||||||
std::fs::write(&path, content)?;
|
std::fs::write(&path, content)?;
|
||||||
written.push(path);
|
written.push(path);
|
||||||
}
|
}
|
||||||
@@ -266,6 +340,29 @@ mod tests {
|
|||||||
assert!(copilot.contains("setup"), "command list must include setup");
|
assert!(copilot.contains("setup"), "command list must include setup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_definition_is_valid_index_yaml() {
|
||||||
|
for name in ROLE_NAMES {
|
||||||
|
let def = role_definition(name).expect("role");
|
||||||
|
assert!(def.starts_with("name:"), "{name}:\n{def}");
|
||||||
|
assert!(def.contains("description:"), "{name}");
|
||||||
|
assert!(def.contains("instructions:"), "{name}");
|
||||||
|
assert!(def.contains("Tu es"), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_def_dir_targets_functions_layout() {
|
||||||
|
let s = aichat_agent_def_dir("am-copilot")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
assert!(s.contains("functions"), "{s}");
|
||||||
|
assert!(s.ends_with("am-copilot"), "{s}");
|
||||||
|
// The registry lives next to the definitions.
|
||||||
|
let registry = aichat_functions_dir().join("agents.txt");
|
||||||
|
assert!(registry.to_string_lossy().ends_with("agents.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agents_dir_prefers_existing_aichat_config() {
|
fn agents_dir_prefers_existing_aichat_config() {
|
||||||
// With a home dir, the unix candidate is ~/.config/aichat/agents.
|
// With a home dir, the unix candidate is ~/.config/aichat/agents.
|
||||||
|
|||||||
+118
-7
@@ -52,6 +52,20 @@ pub trait Runner {
|
|||||||
capture: bool,
|
capture: bool,
|
||||||
) -> Result<RunOutput>;
|
) -> Result<RunOutput>;
|
||||||
|
|
||||||
|
/// Run without the probe timeout: used for legitimately long
|
||||||
|
/// operations (package installs, git clones, ...). The default
|
||||||
|
/// implementation delegates to run().
|
||||||
|
fn run_untimed(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
args: &[String],
|
||||||
|
env: &BTreeMap<String, String>,
|
||||||
|
cwd: Option<&Path>,
|
||||||
|
capture: bool,
|
||||||
|
) -> Result<RunOutput> {
|
||||||
|
self.run(cmd, args, env, cwd, capture)
|
||||||
|
}
|
||||||
|
|
||||||
/// Locate a binary on the PATH (like the which command).
|
/// Locate a binary on the PATH (like the which command).
|
||||||
fn which(&self, bin: &str) -> Option<PathBuf>;
|
fn which(&self, bin: &str) -> Option<PathBuf>;
|
||||||
}
|
}
|
||||||
@@ -73,14 +87,17 @@ impl<'a> SystemRunner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Runner for SystemRunner<'a> {
|
impl<'a> SystemRunner<'a> {
|
||||||
fn run(
|
/// Shared implementation. The timeout applies only to captured
|
||||||
|
/// probes; long-running installs pass None via run_untimed().
|
||||||
|
fn run_inner(
|
||||||
&self,
|
&self,
|
||||||
cmd: &str,
|
cmd: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
env: &BTreeMap<String, String>,
|
env: &BTreeMap<String, String>,
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
capture: bool,
|
capture: bool,
|
||||||
|
timeout: Option<std::time::Duration>,
|
||||||
) -> Result<RunOutput> {
|
) -> Result<RunOutput> {
|
||||||
let display = if args.is_empty() {
|
let display = if args.is_empty() {
|
||||||
cmd.to_string()
|
cmd.to_string()
|
||||||
@@ -102,12 +119,70 @@ impl<'a> Runner for SystemRunner<'a> {
|
|||||||
c.current_dir(dir);
|
c.current_dir(dir);
|
||||||
}
|
}
|
||||||
if capture {
|
if capture {
|
||||||
let out = c
|
c.stdout(std::process::Stdio::piped());
|
||||||
.output()
|
c.stderr(std::process::Stdio::piped());
|
||||||
|
let mut child = c
|
||||||
|
.spawn()
|
||||||
.with_context(|| format!("failed to execute: {display}"))?;
|
.with_context(|| format!("failed to execute: {display}"))?;
|
||||||
let code = out.status.code().unwrap_or(-1);
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
let stdout_pipe = child.stdout.take();
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
let stderr_pipe = child.stderr.take();
|
||||||
|
|
||||||
|
let stdout_handle = std::thread::spawn(move || {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(mut pipe) = stdout_pipe {
|
||||||
|
let _ = std::io::Read::read_to_end(&mut pipe, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
});
|
||||||
|
let stderr_handle = std::thread::spawn(move || {
|
||||||
|
let mut err = Vec::new();
|
||||||
|
if let Some(mut pipe) = stderr_pipe {
|
||||||
|
let _ = std::io::Read::read_to_end(&mut pipe, &mut err);
|
||||||
|
}
|
||||||
|
err
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = if let Some(timeout) = timeout {
|
||||||
|
use wait_timeout::ChildExt;
|
||||||
|
match child.wait_timeout(timeout) {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
Ok(None) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
self.log
|
||||||
|
.warn(format!("command timed out after {timeout:?}: {display}"));
|
||||||
|
let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default())
|
||||||
|
.to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default())
|
||||||
|
.to_string();
|
||||||
|
return Ok(RunOutput {
|
||||||
|
code: -1,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Ok(RunOutput {
|
||||||
|
code: -1,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child
|
||||||
|
.wait()
|
||||||
|
.with_context(|| format!("failed to wait: {display}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout_bytes = stdout_handle.join().unwrap_or_default();
|
||||||
|
let stderr_bytes = stderr_handle.join().unwrap_or_default();
|
||||||
|
let code = status.code().unwrap_or(-1);
|
||||||
|
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
|
||||||
self.log.result(code, &stderr);
|
self.log.result(code, &stderr);
|
||||||
Ok(RunOutput {
|
Ok(RunOutput {
|
||||||
code,
|
code,
|
||||||
@@ -126,6 +201,42 @@ impl<'a> Runner for SystemRunner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Runner for SystemRunner<'a> {
|
||||||
|
/// Probes are bounded by a 30 s timeout so a misbehaving process can
|
||||||
|
/// never hang doctor/version detection.
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
args: &[String],
|
||||||
|
env: &BTreeMap<String, String>,
|
||||||
|
cwd: Option<&Path>,
|
||||||
|
capture: bool,
|
||||||
|
) -> Result<RunOutput> {
|
||||||
|
self.run_inner(
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
cwd,
|
||||||
|
capture,
|
||||||
|
Some(std::time::Duration::from_secs(30)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs and other legitimately long operations run without the
|
||||||
|
/// probe timeout (npm/cargo/go installs can take minutes).
|
||||||
|
fn run_untimed(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
args: &[String],
|
||||||
|
env: &BTreeMap<String, String>,
|
||||||
|
cwd: Option<&Path>,
|
||||||
|
capture: bool,
|
||||||
|
) -> Result<RunOutput> {
|
||||||
|
self.run_inner(cmd, args, env, cwd, capture, None)
|
||||||
|
}
|
||||||
|
|
||||||
fn which(&self, bin: &str) -> Option<PathBuf> {
|
fn which(&self, bin: &str) -> Option<PathBuf> {
|
||||||
which::which(bin).ok()
|
which::which(bin).ok()
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-9
@@ -103,10 +103,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
fn test_app(sandbox_yaml: &str) -> App {
|
fn test_app(sandbox_yaml: &str) -> (tempfile::TempDir, App) {
|
||||||
let guard = tempfile::tempdir().unwrap();
|
let guard = tempfile::tempdir().unwrap();
|
||||||
let dir = guard.path().to_path_buf();
|
let dir = guard.path().to_path_buf();
|
||||||
std::mem::forget(guard);
|
|
||||||
let cfg = dir.join("config.yaml");
|
let cfg = dir.join("config.yaml");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&cfg,
|
&cfg,
|
||||||
@@ -116,7 +115,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||||
crate::app::App::from_cli(cli).unwrap()
|
(guard, crate::app::App::from_cli(cli).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn agent(app: &App) -> &AgentDef {
|
fn agent(app: &App) -> &AgentDef {
|
||||||
@@ -125,7 +124,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_profile_means_unsandboxed() {
|
fn no_profile_means_unsandboxed() {
|
||||||
let app = test_app("");
|
let (_guard, app) = test_app("");
|
||||||
assert!(profile_of(agent(&app)).is_none());
|
assert!(profile_of(agent(&app)).is_none());
|
||||||
let mut env = BTreeMap::new();
|
let mut env = BTreeMap::new();
|
||||||
// No profile: everything allowed, nothing refused.
|
// No profile: everything allowed, nothing refused.
|
||||||
@@ -135,7 +134,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disallowed_command_is_refused_and_journalized() {
|
fn disallowed_command_is_refused_and_journalized() {
|
||||||
let app = test_app(
|
let (_guard, app) = test_app(
|
||||||
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
||||||
);
|
);
|
||||||
let mut env = BTreeMap::new();
|
let mut env = BTreeMap::new();
|
||||||
@@ -161,7 +160,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn allowed_command_and_cwd_pass() {
|
fn allowed_command_and_cwd_pass() {
|
||||||
let cwd = std::env::current_dir().unwrap();
|
let cwd = std::env::current_dir().unwrap();
|
||||||
let app = test_app(&format!(
|
let (_guard, app) = test_app(&format!(
|
||||||
"sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n",
|
"sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n",
|
||||||
cwd.display().to_string().replace('\\', "\\\\")
|
cwd.display().to_string().replace('\\', "\\\\")
|
||||||
));
|
));
|
||||||
@@ -172,7 +171,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cwd_outside_perimeter_is_blocked() {
|
fn cwd_outside_perimeter_is_blocked() {
|
||||||
let app = test_app(
|
let (_guard, app) = test_app(
|
||||||
"sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n",
|
"sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n",
|
||||||
);
|
);
|
||||||
let mut env = BTreeMap::new();
|
let mut env = BTreeMap::new();
|
||||||
@@ -182,7 +181,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn network_off_injects_blocking_proxy_env() {
|
fn network_off_injects_blocking_proxy_env() {
|
||||||
let app = test_app(
|
let (_guard, app) = test_app(
|
||||||
"sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n",
|
"sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n",
|
||||||
);
|
);
|
||||||
let mut env = BTreeMap::new();
|
let mut env = BTreeMap::new();
|
||||||
@@ -194,7 +193,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_sandbox_flag_bypasses_the_profile() {
|
fn no_sandbox_flag_bypasses_the_profile() {
|
||||||
let app = test_app(
|
let (_guard, app) = test_app(
|
||||||
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
||||||
);
|
);
|
||||||
let mut env = BTreeMap::new();
|
let mut env = BTreeMap::new();
|
||||||
|
|||||||
+13
-3
@@ -25,8 +25,9 @@ impl SecretStore for KeyringStore {
|
|||||||
if !idx.iter().any(|k| k == key) {
|
if !idx.iter().any(|k| k == key) {
|
||||||
idx.push(key.to_string());
|
idx.push(key.to_string());
|
||||||
}
|
}
|
||||||
|
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||||
let _ = index.set_password(&idx.join(","));
|
let _ = index.set_password(&json_idx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +52,9 @@ impl SecretStore for KeyringStore {
|
|||||||
let mut idx = self.list().unwrap_or_default();
|
let mut idx = self.list().unwrap_or_default();
|
||||||
if idx.iter().any(|k| k == key) {
|
if idx.iter().any(|k| k == key) {
|
||||||
idx.retain(|k| k != key);
|
idx.retain(|k| k != key);
|
||||||
|
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||||
let _ = index.set_password(&idx.join(","));
|
let _ = index.set_password(&json_idx);
|
||||||
}
|
}
|
||||||
Ok(existed)
|
Ok(existed)
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,15 @@ impl SecretStore for KeyringStore {
|
|||||||
fn list(&self) -> Result<Vec<String>> {
|
fn list(&self) -> Result<Vec<String>> {
|
||||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||||
match index.get_password() {
|
match index.get_password() {
|
||||||
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
|
Ok(v) => {
|
||||||
|
if let Ok(vec) = serde_json::from_str::<Vec<String>>(&v) {
|
||||||
|
Ok(vec)
|
||||||
|
} else if v.contains('\0') {
|
||||||
|
Ok(v.split('\0').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||||
|
} else {
|
||||||
|
Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -370,9 +370,18 @@ fn ws_accept(key: &str) -> String {
|
|||||||
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a single server frame (unmasked): opcode + 7-bit length + payload.
|
/// Write a single server frame (unmasked): opcode + RFC 6455 length + payload.
|
||||||
fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> {
|
fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> {
|
||||||
w.write_all(&[0x80 | opcode, payload.len() as u8])?;
|
let len = payload.len();
|
||||||
|
if len <= 125 {
|
||||||
|
w.write_all(&[0x80 | opcode, len as u8])?;
|
||||||
|
} else if len <= 65535 {
|
||||||
|
w.write_all(&[0x80 | opcode, 126])?;
|
||||||
|
w.write_all(&(len as u16).to_be_bytes())?;
|
||||||
|
} else {
|
||||||
|
w.write_all(&[0x80 | opcode, 127])?;
|
||||||
|
w.write_all(&(len as u64).to_be_bytes())?;
|
||||||
|
}
|
||||||
w.write_all(payload)?;
|
w.write_all(payload)?;
|
||||||
w.flush()?;
|
w.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+67
-16
@@ -14,9 +14,22 @@ use crate::secrets::SecretStore;
|
|||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
/// Known providers offered by the wizard (from the embedded registry plus
|
/// Providers offered by the wizard: every provider defined in the merged
|
||||||
/// the local Ollama runtime and a custom endpoint).
|
/// registry (embedded + user config, sorted), plus the local Ollama runtime
|
||||||
const CHOICES: &[&str] = &["anthropic", "openai", "deepseek", "google", "openrouter", "ollama", "custom"];
|
/// and a custom endpoint. Built dynamically so new registry providers
|
||||||
|
/// (e.g. the QwenCloud token-plan / pay-as-you-go pair) show up without a
|
||||||
|
/// code change — CHOICES used to be a hardcoded list that silently drifted.
|
||||||
|
fn choices(app: &App) -> Vec<String> {
|
||||||
|
let mut v: Vec<String> = crate::providers::defined(&app.config)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
v.sort();
|
||||||
|
if !v.iter().any(|p| p == "ollama") {
|
||||||
|
v.push("ollama".to_string());
|
||||||
|
}
|
||||||
|
v.push("custom".to_string());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the user has not configured a usable default provider yet:
|
/// True when the user has not configured a usable default provider yet:
|
||||||
/// no `settings.default_provider`, or the default provider has no API
|
/// no `settings.default_provider`, or the default provider has no API
|
||||||
@@ -36,10 +49,14 @@ pub fn needs_setup(app: &App) -> bool {
|
|||||||
.is_none()
|
.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_line() -> Result<String> {
|
/// Read one trimmed line from stdin. `None` on EOF — callers must not spin
|
||||||
|
/// on empty input when nothing is connected (pipes, --yes in CI).
|
||||||
|
fn read_line() -> Result<Option<String>> {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
std::io::stdin().read_line(&mut line)?;
|
if std::io::stdin().read_line(&mut line)? == 0 {
|
||||||
Ok(line.trim().to_string())
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(line.trim().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
|
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
|
||||||
@@ -50,7 +67,7 @@ fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
|
|||||||
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
|
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
|
||||||
eprint!("{question} {suffix} ");
|
eprint!("{question} {suffix} ");
|
||||||
std::io::stderr().flush()?;
|
std::io::stderr().flush()?;
|
||||||
let answer = read_line()?.to_lowercase();
|
let answer = read_line()?.unwrap_or_default().to_lowercase();
|
||||||
Ok(match answer.as_str() {
|
Ok(match answer.as_str() {
|
||||||
"" => default_yes,
|
"" => default_yes,
|
||||||
"y" | "yes" | "o" | "oui" | "true" => true,
|
"y" | "yes" | "o" | "oui" | "true" => true,
|
||||||
@@ -61,9 +78,10 @@ fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
|
|||||||
/// Pick a provider from the list (or the current default when --yes).
|
/// Pick a provider from the list (or the current default when --yes).
|
||||||
fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
|
fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
|
||||||
let current = crate::providers::default_name(&app.config);
|
let current = crate::providers::default_name(&app.config);
|
||||||
|
let choices = choices(app);
|
||||||
println!("Provider disponible :");
|
println!("Provider disponible :");
|
||||||
for (i, p) in CHOICES.iter().enumerate() {
|
for (i, p) in choices.iter().enumerate() {
|
||||||
let star = if Some(*p) == current { " (actuel)" } else { "" };
|
let star = if Some(p.as_str()) == current { " (actuel)" } else { "" };
|
||||||
println!(" {}) {}{star}", i + 1, p);
|
println!(" {}) {}{star}", i + 1, p);
|
||||||
}
|
}
|
||||||
if app.cli.yes {
|
if app.cli.yes {
|
||||||
@@ -71,16 +89,18 @@ fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
|
|||||||
return Ok((name, None));
|
return Ok((name, None));
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
print!("Choisissez (1-{}) : ", CHOICES.len());
|
print!("Choisissez (1-{}) : ", choices.len());
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let raw = read_line()?;
|
let Some(raw) = read_line()? else {
|
||||||
|
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
|
||||||
|
};
|
||||||
if let Ok(n) = raw.parse::<usize>() {
|
if let Ok(n) = raw.parse::<usize>() {
|
||||||
if (1..=CHOICES.len()).contains(&n) {
|
if (1..=choices.len()).contains(&n) {
|
||||||
let name = CHOICES[n - 1];
|
let name = &choices[n - 1];
|
||||||
if name == "custom" {
|
if name == "custom" {
|
||||||
print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
|
print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let url = read_line()?;
|
let url = read_line()?.unwrap_or_default();
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -126,7 +146,9 @@ fn pick_model(app: &App, name: &str) -> Result<String> {
|
|||||||
loop {
|
loop {
|
||||||
print!("Modèle par défaut : ");
|
print!("Modèle par défaut : ");
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let raw = read_line()?;
|
let Some(raw) = read_line()? else {
|
||||||
|
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
|
||||||
|
};
|
||||||
if let Ok(n) = raw.parse::<usize>() {
|
if let Ok(n) = raw.parse::<usize>() {
|
||||||
if n >= 1 && n <= models.len() {
|
if n >= 1 && n <= models.len() {
|
||||||
return Ok(models[n - 1].clone());
|
return Ok(models[n - 1].clone());
|
||||||
@@ -134,7 +156,7 @@ fn pick_model(app: &App, name: &str) -> Result<String> {
|
|||||||
if n == models.len() + 1 {
|
if n == models.len() + 1 {
|
||||||
print!("Nom du modèle : ");
|
print!("Nom du modèle : ");
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let custom = read_line()?;
|
let custom = read_line()?.unwrap_or_default();
|
||||||
if !custom.is_empty() {
|
if !custom.is_empty() {
|
||||||
return Ok(custom);
|
return Ok(custom);
|
||||||
}
|
}
|
||||||
@@ -347,6 +369,35 @@ mod tests {
|
|||||||
assert!(needs_setup(&app));
|
assert!(needs_setup(&app));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wizard_offers_every_registry_provider() {
|
||||||
|
// Regression (v1.1.8): the wizard used a hardcoded CHOICES list and
|
||||||
|
// the QwenCloud providers added to the registry never appeared.
|
||||||
|
// The list must now track the merged registry + ollama + custom.
|
||||||
|
let app = app_with("");
|
||||||
|
let c = choices(&app);
|
||||||
|
for p in [
|
||||||
|
"anthropic",
|
||||||
|
"openai",
|
||||||
|
"deepseek",
|
||||||
|
"google",
|
||||||
|
"openrouter",
|
||||||
|
"qwen-cloud",
|
||||||
|
"qwen-cloud-token-plan",
|
||||||
|
"ollama",
|
||||||
|
"custom",
|
||||||
|
] {
|
||||||
|
assert!(c.iter().any(|x| x == p), "wizard must offer {p}: {c:?}");
|
||||||
|
}
|
||||||
|
// User-defined providers show up too, ollama is never duplicated.
|
||||||
|
let app2 = app_with(
|
||||||
|
" default_provider: anthropic\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n my-llm:\n base_url: https://llm.internal/v1\n",
|
||||||
|
);
|
||||||
|
let c2 = choices(&app2);
|
||||||
|
assert_eq!(c2.iter().filter(|x| *x == "ollama").count(), 1, "{c2:?}");
|
||||||
|
assert!(c2.iter().any(|x| x == "my-llm"), "{c2:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn needs_setup_false_for_local_ollama() {
|
fn needs_setup_false_for_local_ollama() {
|
||||||
let app = app_with(
|
let app = app_with(
|
||||||
|
|||||||
+10
-3
@@ -7,8 +7,9 @@
|
|||||||
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
|
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
|
||||||
//! spawned in the foreground, exactly as the user would run it;
|
//! spawned in the foreground, exactly as the user would run it;
|
||||||
//! - exec mode (`--exec`): aichat is asked to generate the command with
|
//! - exec mode (`--exec`): aichat is asked to generate the command with
|
||||||
//! `--dry-run` (it prints the command and never runs it), then the
|
//! `--code` (it prints only the command and never runs it — aichat is
|
||||||
//! generated command is classified (safe / risky), a confidence score
|
//! invoked without `--execute`), then the generated command is
|
||||||
|
//! classified (safe / risky), a confidence score
|
||||||
//! is estimated, the configured safety policy is applied (dry-run by
|
//! is estimated, the configured safety policy is applied (dry-run by
|
||||||
//! default), and only then — after confirmation when required — is the
|
//! default), and only then — after confirmation when required — is the
|
||||||
//! command executed through the user's shell.
|
//! command executed through the user's shell.
|
||||||
@@ -283,7 +284,13 @@ pub fn generate(
|
|||||||
role_args: &[String],
|
role_args: &[String],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let agent = require_agent(app, "aichat")?;
|
let agent = require_agent(app, "aichat")?;
|
||||||
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
|
// aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to
|
||||||
|
// skip the API call entirely (it echoes the request), so the old
|
||||||
|
// `--execute --dry-run` no longer yields the generated command. Plain
|
||||||
|
// chat with `--code` extracts and prints only the command (same output
|
||||||
|
// shape, works on every aichat version). aichat NEVER executes here —
|
||||||
|
// am enforces the safety policy before running the command itself.
|
||||||
|
let mut extra_args: Vec<String> = vec!["--code".to_string()];
|
||||||
extra_args.extend(role_args.iter().cloned());
|
extra_args.extend(role_args.iter().cloned());
|
||||||
for f in files {
|
for f in files {
|
||||||
extra_args.push("-f".to_string());
|
extra_args.push("-f".to_string());
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ const WEB: &str = r#"# Généré par 'am init --template web' — projet {projec
|
|||||||
# Répertoire : {dir}
|
# Répertoire : {dir}
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
groups:
|
groups:
|
||||||
dev: [claude-code, opencode, gemini-cli]
|
dev: [claude-code, opencode, antigravity-cli]
|
||||||
profiles:
|
profiles:
|
||||||
dev:
|
dev:
|
||||||
agent: claude-code
|
agent: claude-code
|
||||||
|
|||||||
+4
-1
@@ -134,7 +134,9 @@ fn index_page(app: &App) -> HttpResult {
|
|||||||
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
|
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
|
||||||
// Then every {{label}} token: replaced by the localized label (the
|
// Then every {{label}} token: replaced by the localized label (the
|
||||||
// French text is both the key and the fallback, so nothing survives).
|
// French text is both the key and the fallback, so nothing survives).
|
||||||
while let Some(start) = html.find("{{") {
|
let mut cursor = 0;
|
||||||
|
while let Some(rel_start) = html[cursor..].find("{{") {
|
||||||
|
let start = cursor + rel_start;
|
||||||
let Some(rel_end) = html[start + 2..].find("}}") else {
|
let Some(rel_end) = html[start + 2..].find("}}") else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
@@ -142,6 +144,7 @@ fn index_page(app: &App) -> HttpResult {
|
|||||||
let key = html[start + 2..end].to_string();
|
let key = html[start + 2..end].to_string();
|
||||||
let translated = crate::i18n::tr_in(lang, &key);
|
let translated = crate::i18n::tr_in(lang, &key);
|
||||||
html.replace_range(start..end + 2, translated);
|
html.replace_range(start..end + 2, translated);
|
||||||
|
cursor = start + translated.len();
|
||||||
}
|
}
|
||||||
HttpResult {
|
HttpResult {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
Reference in New Issue
Block a user