Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f29baea5 | ||
|
|
cf53c98d98 | ||
|
|
991c79de50 | ||
|
|
d911ce5d92 | ||
|
|
fa2c42d505 | ||
|
|
e637c066fe | ||
|
|
66a3eebbd6 | ||
|
|
e71cffb3c0 | ||
|
|
7fb65c257d | ||
|
|
068940495a | ||
|
|
d71c3dd34d |
+2
-2
@@ -230,7 +230,7 @@ settings:
|
||||
|
||||
aliases:
|
||||
cc: claude-code
|
||||
gemini: gemini-cli
|
||||
gemini: antigravity-cli
|
||||
|
||||
groups:
|
||||
dev: [claude-code, aider, codex]
|
||||
@@ -506,7 +506,7 @@ Disponibles avant ou après la sous-commande.
|
||||
```yaml
|
||||
aliases:
|
||||
cc: claude-code
|
||||
gemini: gemini-cli
|
||||
gemini: antigravity-cli
|
||||
```
|
||||
|
||||
`am start cc` démarre `claude-code`.
|
||||
|
||||
Generated
+23
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "1.0.6"
|
||||
version = "1.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -36,6 +36,7 @@ dependencies = [
|
||||
"nu-ansi-term",
|
||||
"ratatui",
|
||||
"reedline",
|
||||
"rpassword",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1779,6 +1780,27 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "1.0.6"
|
||||
version = "1.1.9"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
@@ -38,6 +38,7 @@ toml = "0.8"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wait-timeout = "0.2"
|
||||
keyring = { version = "3", features = ["windows-native", "linux-native"] }
|
||||
rpassword = "7"
|
||||
which = "7"
|
||||
zip = "0.6"
|
||||
crossterm = "0.29"
|
||||
|
||||
@@ -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… **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,
|
||||
Go, Rust…) prise en charge pour vous.
|
||||
|
||||
@@ -11,7 +11,7 @@ Go, Rust…) prise en charge pour vous.
|
||||
|
||||
## ✨ 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.
|
||||
- 📦 **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
|
||||
@@ -79,7 +105,8 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
| 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>] | 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) |
|
||||
| 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) |
|
||||
@@ -131,7 +158,7 @@ Les variables d'environnement `AGENT_MANAGER_DATA`, `AGENT_MANAGER_STATE` et `AG
|
||||
|
||||
#### Cycle d'installation (`am install <agent>`)
|
||||
|
||||
1. **Résolution du catalogue** — `am` fusionne le catalogue embarqué (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.
|
||||
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 :
|
||||
@@ -356,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é (77 agents)
|
||||
4. catalogue embarqué (78 agents)
|
||||
|
||||
Vos définitions **complètent ou surchargent** le catalogue par nom.
|
||||
|
||||
|
||||
+221
-6
@@ -1,7 +1,13 @@
|
||||
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
||||
|
||||
> **Version du document : 2.6** · Statut : phases 0 à 3 livrées
|
||||
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.0.6
|
||||
> **Version du document : 3.1** · Statut : phases 0 à 3 livrées
|
||||
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.5
|
||||
>
|
||||
> 🧭 **Bilan du 2026-08-23** : tout le périmètre versionné (J0 → J3, v0.7.0,
|
||||
> épique v1.1.0) est livré — **0 issue ouverte** sur Gitea. Il reste :
|
||||
> **1 bug prioritaire** (hang de `cargo test` / `am doctor`, §15.4),
|
||||
> 3 fonctionnalités P1 jamais découpées en issues (§15.2), des activités de
|
||||
> release (§15.3) et des idées neuves proposées (§16).
|
||||
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
||||
>
|
||||
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
||||
@@ -55,6 +61,100 @@
|
||||
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
|
||||
> les deux entrées pour la compatibilité maximale. Chemin du message
|
||||
> cargo install corrigé (`.cargo\bin\am.exe`).
|
||||
>
|
||||
> 🚀 **Épique v1.1.0 (2026-08-20)** : **Onboarding, Copilot & Rôles aichat**
|
||||
> (Axe 14) — `am setup` (wizard provider + token keyring + modèle + ping),
|
||||
> installation automatique d'aichat (wizard + `am ai` + installateurs),
|
||||
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
|
||||
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
|
||||
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
|
||||
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
|
||||
> validateur : « unknown field `providers` »). Le registre vivant sous
|
||||
> `settings.providers`, le wizard écrit désormais
|
||||
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
|
||||
> base_url quand un bloc provider est écrit). Test de régression
|
||||
> `setup_persists_under_settings_providers`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.2 (2026-08-20)** :
|
||||
> - `am setup` : **openrouter** ajouté au choix des providers (base URL,
|
||||
> token via OPENROUTER_API_KEY, 6 modèles déclarés proposés) ;
|
||||
> le choix du modèle liste les modèles disponibles du provider.
|
||||
> - `am ai` sans `--role` utilise **am-copilot par défaut** (régénération
|
||||
> automatique des rôles si le fichier manque, dégradation douce).
|
||||
> - Bug `am migrate --import` : la config importée était écrite dans le
|
||||
> répertoire config GLOBAL (user_config_dir) au lieu du config actif de
|
||||
> l'app — l'import écrasait la config réelle de la machine (et la suite
|
||||
> de tests détruisait la config utilisateur à chaque `cargo test`).
|
||||
> L'import respecte désormais `app.paths.config_dir` (--config/isolated).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.3 (2026-08-21)** : fix `resolve_exec` — pour un
|
||||
> agent INSTALLÉ avec un binaire enregistré, le premier token de `run:`
|
||||
> (ex. `picoclaw`) n'était jamais consommé : `am start <agent>` lançait
|
||||
> `<bin> <token-run>` (picoclaw rejetait son propre nom comme
|
||||
> sous-commande inconnue ; aichat recevait « aichat » comme prompt
|
||||
> one-shot). Le token est désormais consommé dans la branche « binaire
|
||||
> enregistré existant » (les branches non-installé/binaire manquant
|
||||
> étaient déjà correctes). Test de régression
|
||||
> `resolve_exec_installed_agent_does_not_leak_run_token`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.4 (2026-08-21)** : hints de configuration
|
||||
> post-install — nouveau champ `setup_hints` dans la définition d'un agent
|
||||
> (config.yaml) : commandes de configuration manuelle affichées après
|
||||
> l'install quand l'agent gère sa propre config (ex. picoclaw
|
||||
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
||||
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
||||
> premier agent du catalogue à les déclarer.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec
|
||||
> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown
|
||||
> `agents/<name>.md` ≤ 0.29 est remplacé par une définition
|
||||
> `functions/agents/<name>/index.yaml` + un registre `functions/agents.txt`).
|
||||
> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats
|
||||
> (compatibilité totale) et `am ai` détecte la définition nouvelle génération
|
||||
> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte
|
||||
> par défaut (dossier courant) est filtré par am (`.git`, `target`,
|
||||
> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB)
|
||||
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
|
||||
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
|
||||
> n'appelle plus l'API, il écho la requête).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.6 (2026-08-24)** : correctifs de robustesse —
|
||||
> timeout de 30 s sur les commandes capturées (doctor/runner ne pendent plus
|
||||
> si un agent ne répond pas, pipes vidés dans des threads pour éviter les
|
||||
> deadlocks), `is_running` Windows via l'API Win32 (OpenProcess /
|
||||
> GetExitCodeProcess, plus fiable que tasklist), rejet des chemins unsafe
|
||||
> (path traversal) dans `am migrate --import`, frames WebSocket RFC 6455
|
||||
> avec vraies longueurs > 125 octets, boucle de traduction i18n de la page
|
||||
> web corrigée (plus de boucle infinie), index du trousseau (keyring)
|
||||
> sérialisé en JSON (clés contenant des virgules).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.7 (2026-09-01)** : Gemini CLI a cessé de servir les
|
||||
> comptes Google gratuits/Pro/Ultra le 2026-06-18 — remplacé par **Antigravity
|
||||
> CLI** (`agy`, binaire Go fermé, même harnais qu'Antigravity 2.0). Nouveau
|
||||
> catalogue : entrée `antigravity-cli` (release GitHub
|
||||
> google-antigravity/antigravity-cli), `gemini-cli` marqué déprécié
|
||||
> (`installable: false`, clé API payante seulement), alias `gemini`/`agy`
|
||||
> redirigés, groupe `mainstream` et gabarit web mis à jour. Import des
|
||||
> extensions/skills Gemini : `agy plugin import gemini`.
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.8 (2026-09-01)** : providers **QwenCloud** ajoutés au
|
||||
> registre par défaut — `qwen-cloud` (pay-as-you-go, Model Studio/DashScope,
|
||||
> dashscope-intl…/compatible-mode/v1) et `qwen-cloud-token-plan` (abonnement
|
||||
> Credits, token-plan.ap-southeast-1…/compatible-mode/v1, modèles restreints
|
||||
> dont qwen3.8-max). `qwen-code` pointe sur qwen-cloud par défaut (env_map
|
||||
> OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer
|
||||
> sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via
|
||||
> `am providers set-token`).
|
||||
>
|
||||
> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses
|
||||
> providers dans une constante codée en dur — les QwenCloud ajoutés au
|
||||
> registre n'y apparaissaient pas. La liste est maintenant construite depuis
|
||||
> le registre fusionné (embarqué + config utilisateur), ollama et custom en
|
||||
> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne
|
||||
> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion
|
||||
> `--yes`).
|
||||
|
||||
---
|
||||
|
||||
@@ -71,6 +171,8 @@
|
||||
9. [Indicateurs de succès (KPI)](#9--indicateurs-de-succès-kpi)
|
||||
10. [Risques & garde-fous](#10--risques--garde-fous)
|
||||
11. [Par où commencer](#11--par-où-commencer)
|
||||
12. [Bilan & reste à faire (2026-08-23)](#15--bilan--reste-à-faire-analyse-du-2026-08-23)
|
||||
13. [Idées neuves (post-v1.1.5)](#16--idées-neuves-post-v115)
|
||||
|
||||
---
|
||||
|
||||
@@ -300,8 +402,8 @@ consultable, reprenable, archivable.
|
||||
| Fonctionnalité | Effort | Phase |
|
||||
|---|---|---|
|
||||
| Recherche fuzzy dans am search (typos, scoring, « vouliez-vous dire ») | M | P1 |
|
||||
| am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) | S | P1 |
|
||||
| am news — dernières releases des agents installés (API GitHub/Gitea, cache) | M | P1 |
|
||||
| ⬜ am compare a b — tableau côte à côte (méthode, deps, catégorie, activité) — **non livré, sans issue** | S | P1 |
|
||||
| ⬜ am news — dernières releases des agents installés (API GitHub/Gitea, cache) — **non livré, sans issue** | M | P1 |
|
||||
| Catalogue distant : am catalog update / am catalog add <url> | M | P2 |
|
||||
| am suggest "un agent pour du Python" — tags + usage réel | M | P2 |
|
||||
| am lab — benchmark : même tâche sur N agents, comparaison durée/résultat/coût | L | P2 |
|
||||
@@ -340,7 +442,7 @@ consultable, reprenable, archivable.
|
||||
| Favoris : am favorite / am unfavorite + ⭐ dans am list | S | P1 |
|
||||
| Notes : am note claude-code "utiliser pour …" | S | P1 |
|
||||
| Tags personnels : am tag claude-code python | S | P1 |
|
||||
| Raccourcis de commandes : settings.shortcuts (i → install, s → start) | S | P1 |
|
||||
| ⬜ Raccourcis de commandes : settings.shortcuts (i → install, s → start) — **non livré, sans issue** | S | P1 |
|
||||
| Profils d'environnement : am profile dev / prod (env + args + agent par profil) | M | P1 |
|
||||
| Thèmes couleurs du REPL | S | P2 |
|
||||
|
||||
@@ -456,6 +558,117 @@ aichat -f . "résume les données de ces fichiers JSON"
|
||||
| `am note` | Extrait des action items d'un texte/réunion | Axe 10 |
|
||||
| `am search-web "..."` | Recherche web rapide et réponse synthétisée | Axe 7 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 🚀 Onboarding & Copilot aichat (épique v1.1.0)
|
||||
|
||||
> Objectif : réduire le **Time-To-First-Value à 0** — rendre `am` opérationnel
|
||||
> pour l'IA locale dès la première seconde, et faire d'aichat le copilot de
|
||||
> agent-manager. Livré en **v1.1.0 (2026-08-20)**.
|
||||
|
||||
| Fonctionnalité | Description | Statut |
|
||||
|---|---|---|
|
||||
| ✅ **Wizard d'onboarding (`am setup`)** | Provider (anthropic, openai, deepseek, google, ollama, custom), token masqué → trousseau OS (keyring, jamais en clair), modèle par défaut, ping API non-bloquant. Déclenché par `am ai`/`am ask`/banner REPL quand aucun provider n'est configuré (message discret, jamais bloquant), ré-exécutable à la main (mode revoir) | ✅ v1.1.0 |
|
||||
| ✅ **Installation automatique d'aichat** | Étape du wizard « Installer le moteur IA (aichat) ? [Y/n] » → `am install aichat` (catalogue) ; `am ai` sans aichat propose l'installation ; install.ps1/install.sh enchaînent sur `am setup` si stdin interactif | ✅ v1.1.0 |
|
||||
| ✅ **Rôles copilot aichat (am-*)** | 6 agents générés dans `~/.config/aichat/agents/` (ou %APPDATA%\\aichat\\agents) : am-copilot (guide & catalogue), am-operator (shell sécurisé), am-dev (code & git), am-do (exécution pure pour --exec), am-analyst (coûts/logs/stats), am-orchestrator (groupes/lab). Prompts bilingues (FR par défaut), privilégient les contrats `--json` | ✅ v1.1.0 |
|
||||
| ✅ **`am ai --role <nom>`** | Rôle copilot passé à aichat (`--agent`) ; validation du fichier généré ; complétion REPL des rôles am-* ; config aichat créée (provider + modèle) seulement si absente | ✅ v1.1.0 |
|
||||
| ✅ **Settings dédiés** | `settings.shell_ai` (default_safety dry-run/confirm/auto, risky_patterns) documenté dans config.yaml ; module `src/setup.rs` + `src/roles.rs` | ✅ v1.1.0 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 🧭 Bilan & reste à faire (analyse du 2026-08-23)
|
||||
|
||||
> Analyse complète du dépôt le 2026-08-23 : code (v1.1.5, commit e637c06),
|
||||
> historique git, tracker Gitea, artefacts de release.
|
||||
|
||||
### 15.1 ✅ Ce qui est livré
|
||||
|
||||
- **Tout le périmètre versionné** : J0/v0.3.0, J1/v0.4.1, J2/v0.6.0,
|
||||
v0.7.0 (providers), J3/v1.0.0, épique v1.1.0 (onboarding/copilot) et les
|
||||
maintenances v1.0.1 → v1.1.5.
|
||||
- **0 issue ouverte** sur le tracker Gitea (issues #1 → #97 clôturées).
|
||||
- `main` synchronisé avec `origin/main` ; Cargo.toml/Cargo.lock en
|
||||
**v1.1.5** ; suite de tests : **455 tests passés, 0 échec** le
|
||||
2026-08-23 (23 suites, hors `doctor_test` qui hang — §15.4).
|
||||
- Vérifié présent dans le code : favoris/notes/tags (#39), dashboard,
|
||||
monitor, web, serve, lab, sync, migrate, models, sandbox, registry,
|
||||
ask, ai, setup + rôles copilot, self-update, services, schedule, i18n,
|
||||
packaging, complétions, man pages.
|
||||
|
||||
### 15.2 ⬜ Fonctionnalités du roadmap NON livrées (jamais découpées en issues)
|
||||
|
||||
| Fonctionnalité | Axe | Effort | Constat dans le code |
|
||||
|---|---|---|---|
|
||||
| `am compare a b` — tableau côte à côte | Axe 7 | S | aucune commande `Compare` dans `src/cli.rs` |
|
||||
| `am news` — dernières releases des agents installés | Axe 7 | M | aucun module, 0 occurrence dans `src/` |
|
||||
| `settings.shortcuts` — raccourcis REPL (i → install…) | Axe 10 | S | 0 occurrence de `shortcuts` dans `src/` |
|
||||
| Extensions sœurs Axe 13 (`am summarize`, `am explain`, `am fix`, …) | Axe 13 | M | absentes — voir §13.5 et l'idée N10 (§16) |
|
||||
|
||||
➡️ Suggestion : créer 3 issues Gitea (compare, news, shortcuts) en P1 —
|
||||
petits efforts à forte valeur, seuls restes des jalons « cockpit ».
|
||||
|
||||
### 15.3 📦 Activités de release / opérations
|
||||
|
||||
| Activité | Détail |
|
||||
|---|---|
|
||||
| **Publier la release v1.1.5** | Le commit `e637c06` (fix aichat ≥ 0.30) est sur `main` **sans tag ni release Gitea** → installateurs et `am self-update` servent encore la v1.1.4. Taguer `v1.1.5` pour déclencher `.gitea/workflows/release.yml`. |
|
||||
| **Compléter les assets v1.1.4** | La release v1.1.4 publiée ne contient **que** `am-windows-x86_64.zip` : binaires Linux (x86_64/aarch64) et macOS absents → les installateurs Linux/macOS retombent sur `cargo install --git`. |
|
||||
| **Artéfacts `dist/` obsolètes** | Les archives de `dist/` datent de l'ère v0.6.0 (2026-08-19) ; laisser la CI régénérer celles de v1.1.5. |
|
||||
| **Stash obsolète** | `stash@{0}` « WIP issue #39 : favoris, notes, tags » (2026-08-17 09:12) — la fonctionnalité a été livrée autrement dans v0.4.1 (champs présents dans `src/state.rs`) ; à purger (`git stash drop`) après confirmation. |
|
||||
|
||||
### 15.4 🐛 Bug découvert pendant l'analyse (2026-08-23) — à corriger en priorité
|
||||
|
||||
**`cargo test` se suspend** sur `doctor_test::report_contains_core_checks`
|
||||
sur cette machine (reproduit 3 fois, dont une exécution > 20 min), et le
|
||||
même hang peut frapper l'utilisateur final via `am doctor`.
|
||||
|
||||
- **Cause racine** : `SystemRunner::run` (`src/runner.rs`, branche
|
||||
`capture`) attend `Command::output()` **sans timeout** et sans
|
||||
protection contre l'héritage des pipes. `am doctor` sonde les outils du
|
||||
PATH (`ollama ls`, `llama-server --version`, `git --version`…) ; quand
|
||||
le processus fils engendre un petit-enfant qui conserve les handles
|
||||
stdout/stderr (cas réel observé : `conhost.exe` alloué pour
|
||||
`ollama.exe ls` lancé sans console), `output()` attend l'EOF des pipes
|
||||
**indéfiniment**.
|
||||
- **Reproduction** : `target\debug\deps\doctor_test-*.exe --nocapture
|
||||
report_contains_core_checks` → hang > 40 s avec enfants `ollama.exe ls`
|
||||
+ `conhost.exe` sous le processus de test ; l'ancien binaire de test du
|
||||
2026-08-21 passait en 2,2 s (déclenchement du conhost non déterministe).
|
||||
- **Périmètre touché** : tout ce qui passe par `SystemRunner::run` avec
|
||||
capture — `am doctor` donc, et les sondes d'installation. (`am models`,
|
||||
`am lab`, les plugins et la sonde externe utilisent déjà `wait_timeout`
|
||||
et sont immunisés.)
|
||||
- **Correctif proposé** : aligner `SystemRunner::run` sur le pattern déjà
|
||||
éprouvé dans `src/models.rs` / `src/lab.rs` / `src/plugins.rs` : spawn
|
||||
+ `wait_timeout` (30 s par défaut, `settings.probe_timeout_secs`) +
|
||||
drain des pipes avant l'attente + kill de l'arbre à l'expiration — les
|
||||
commentaires de `lab.rs` et `plugins.rs` documentent exactement ce
|
||||
deadlock. À compléter par un test de régression (faux binaire qui
|
||||
engendre un enfant garde-fou).
|
||||
- **Contournement immédiat** : lancer les suites une à une, sans
|
||||
`--test doctor_test`.
|
||||
|
||||
---
|
||||
|
||||
## 16. 💡 Idées neuves (post-v1.1.5)
|
||||
|
||||
> Ajoutées lors de l'analyse du 2026-08-23. Toutes ⬜ (proposées), non
|
||||
> planifiées. Respectent l'ADN du projet : zéro dépendance runtime, local
|
||||
> par défaut, contrats `--json` stables.
|
||||
|
||||
| # | Idée | Description | Axe | Effort | Phase |
|
||||
|---|---|---|---|---|---|
|
||||
| N1 | ⬜ **`am mcp` — serveur MCP** | Exposer am comme serveur *Model Context Protocol* : les agents (Claude Code, Codex, aichat…) découvrent et pilotent le parc via des outils MCP (`am__list`, `am__stats`, `am__sessions`, `am__start`…). am devient le hub de coopération entre agents. | nouveau | L | P3 |
|
||||
| N2 | ⬜ **Budgets & alertes de coûts** | Budget par agent/projet avec alertes à seuils (80 % / 100 %) et blocage doux, branché sur le suivi des coûts (#49). | Axe 1 | M | P2 |
|
||||
| N3 | ⬜ **Webhooks de notification** | Alertes `am watch` / `schedule` / `doctor --watch` vers un webhook (Discord, Slack, n8n, Gotify) en plus du `--notify` local. | Axe 6 | S | P2 |
|
||||
| N4 | ⬜ **`am test <agent>` — smoke test** | Vérification normalisée post-install/post-update : le binaire répond, ping provider, tâche minimale — généralise le ping de `am setup` à tout le catalogue. | Axe 7 | M | P2 |
|
||||
| N5 | ⬜ **`am diff` — comparaison de configs** | Diff de config/état entre deux machines ou deux points de sync ; complète `am sync` (#66) et `am migrate` (#68) pour le multi-machine. | Axe 9 | S | P2 |
|
||||
| N6 | ⬜ **Transcripts de session** | `am sessions transcript <id>` : export Markdown lisible et partageable d'une session (commandes + sorties), complément de `sessions export` (#53). | Axe 4 | S | P2 |
|
||||
| N7 | ⬜ **`am doctor --report`** | Rapport de diagnostic complet anonymisé (versions, doctor, événements récents) prêt à joindre à un bug report. | Axe 12 | S | P2 |
|
||||
| N8 | ⬜ **Catalogue air-gapped** | `am catalog snapshot` : instantané hors-ligne complet du catalogue distant (définitions + binaires) pour machines sans internet. | Axe 7 | M | P3 |
|
||||
| N9 | ⬜ **Export OpenTelemetry** | Export opt-in du journal `events.jsonl` vers OTLP/Grafana pour les équipes qui ont déjà une pile d'observabilité. | Axe 1 | M | P3 |
|
||||
| N10 | ⬜ **Épique « Boîte à outils IA »** | Formaliser les commandes sœurs §13.5 sur les rôles copilot aichat (v1.1.0) : `am commit`, `am review`, `am explain`, `am fix`, `am summarize`, `am doc`, `am translate`, `am ask-code`, `am search-web`, `am note`. Une commande = un rôle + un raccourci de prompt, sécurité `--exec` héritée de `am ai`. | Axe 13 | L | P2 → P3 |
|
||||
| N11 | ⬜ **Suite de tests 100 % hermétique** | `update_policy_test` (> 60 s/test) appelle réellement l'API GitHub via `installers::latest_version` ; prévoir un mode hors-ligne des sondes de version (flag test/offline ou mock), pour une suite rapide et déterministe partout. | Axe 12 | S | P1 |
|
||||
|
||||
---
|
||||
|
||||
@@ -637,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
|
||||
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
|
||||
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97),
|
||||
v1.0.6 (installateur Windows accepte l'entrée am-windows-x86_64.exe).*
|
||||
v1.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).*
|
||||
|
||||
+66
-5
@@ -108,11 +108,38 @@ settings:
|
||||
base_url: https://generativelanguage.googleapis.com/v1beta
|
||||
default_model: gemini-2.5-pro
|
||||
models: [gemini-2.5-pro, gemini-2.5-flash]
|
||||
# QwenCloud (Alibaba) — deux modes de facturation, deux providers :
|
||||
# - qwen-cloud-token-plan : abonnement Credits (Personal/Team), clé
|
||||
# dédiée Token Plan, modèles restreints (voir docs.qwencloud.com).
|
||||
# Base URL internationale (ap-southeast-1) ; la variante Pékin
|
||||
# token-plan.cn-beijing.maas.aliyuncs.com sert la Chine.
|
||||
# - qwen-cloud : pay-as-you-go (clé sk- Model Studio / DashScope).
|
||||
qwen-cloud-token-plan:
|
||||
base_url: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
|
||||
default_model: qwen3.8-max
|
||||
models: [qwen3.8-max, qwen3.8-flash, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, deepseek-v4-pro, glm-5.2]
|
||||
qwen-cloud:
|
||||
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
|
||||
default_model: qwen3.7-plus
|
||||
models: [qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, qwen3-coder-plus, qwen3-coder-next, deepseek-v4-pro]
|
||||
openrouter:
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
default_model: deepseek/deepseek-chat-v3-0324
|
||||
models:
|
||||
- deepseek/deepseek-chat-v3-0324
|
||||
- anthropic/claude-sonnet-4.5
|
||||
- openai/gpt-4o
|
||||
- google/gemini-2.5-pro
|
||||
- meta-llama/llama-3.3-70b-instruct
|
||||
- mistralai/mistral-large-2411
|
||||
|
||||
# --- Command aliases ----------------------------------------------------------
|
||||
aliases:
|
||||
cc: claude-code
|
||||
gemini: gemini-cli
|
||||
# Gemini CLI (npm @google/gemini-cli) ne sert plus les comptes Google
|
||||
# gratuits/Pro/Ultra depuis le 2026-06-18 — remplacé par Antigravity CLI.
|
||||
gemini: antigravity-cli
|
||||
agy: antigravity-cli
|
||||
gh-copilot: github-copilot
|
||||
q: amazon-q
|
||||
droid: factory-droid
|
||||
@@ -126,7 +153,7 @@ groups:
|
||||
dev: [claude-code, aider, codex]
|
||||
local: [atomic-agent, nanocoder, gptme, goose]
|
||||
claw: [openclaw, zeroclaw, picoclaw]
|
||||
mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code]
|
||||
mainstream: [claude-code, opencode, antigravity-cli, codex, qwen-code]
|
||||
|
||||
# ============================================================================
|
||||
# Agents
|
||||
@@ -280,9 +307,26 @@ agents:
|
||||
note: "projet en mode musée — maintenance 100 % automatisée"
|
||||
tags: [rust, clean-room, fork]
|
||||
|
||||
- name: antigravity-cli
|
||||
display_name: "Antigravity CLI"
|
||||
description: "Agent terminal officiel de Google (successseur de Gemini CLI, fermé, binaire Go). Même harnais d'agents qu'Antigravity 2.0 : multi-agents asynchrones, skills, hooks, plugins, MCP. Auth Google via keyring système, quota partagé avec l'app desktop (surveiller /usage)."
|
||||
category: coding-agent
|
||||
website: https://antigravity.google/cli
|
||||
install:
|
||||
type: binary
|
||||
repo: google-antigravity/antigravity-cli
|
||||
binary: agy
|
||||
run: agy
|
||||
provider: google
|
||||
setup_hints:
|
||||
- "agy (premier lancement : setup interactif + auth Google via le trousseau)"
|
||||
- "agy plugin import gemini (importe extensions/skills de l'ancien Gemini CLI)"
|
||||
tags: [google, mainstream, antigravity]
|
||||
note: "Remplace Gemini CLI : le 2026-06-18, Gemini CLI a cessé de servir les comptes Google gratuits/Pro/Ultra. Import des extensions/skills Gemini : 'agy plugin import gemini' ; GEMINI.md et ~/.gemini/ restent lus (config native : ~/.gemini/antigravity-cli/, skills workspace : .agents/skills/, MCP : mcp_config.json avec clé 'serverUrl')."
|
||||
|
||||
- name: gemini-cli
|
||||
display_name: "Gemini CLI"
|
||||
description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche."
|
||||
display_name: "Gemini CLI (déprécié)"
|
||||
description: "Ancien agent terminal Google (Apache-2.0). Ne sert plus les comptes Google gratuits/Pro/Ultra depuis le 2026-06-18 ; reste utilisable avec une clé API Gemini payante. Migrer vers antigravity-cli."
|
||||
category: coding-agent
|
||||
website: https://github.com/google-gemini/gemini-cli
|
||||
install:
|
||||
@@ -291,7 +335,9 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: gemini
|
||||
tags: [google, mainstream]
|
||||
installable: false
|
||||
tags: [google, deprecated]
|
||||
note: "déprécié — remplacé par Antigravity CLI ('am install antigravity-cli'), voir la note de cette entrée"
|
||||
|
||||
- name: codex
|
||||
display_name: "Codex CLI"
|
||||
@@ -425,6 +471,15 @@ agents:
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: qwen-code
|
||||
provider: qwen-cloud
|
||||
config:
|
||||
env_map:
|
||||
api_key: OPENAI_API_KEY
|
||||
model: OPENAI_MODEL
|
||||
base_url: OPENAI_BASE_URL
|
||||
setup_hints:
|
||||
- "am install qwen-code --provider qwen-cloud-token-plan (abonnement Credits Token Plan au lieu du pay-as-you-go)"
|
||||
- "am providers set-token qwen-cloud-token-plan (clé dédiée Token Plan ; pay-as-you-go : clé sk- Model Studio)"
|
||||
tags: [alibaba, qwen]
|
||||
|
||||
- name: grok-build
|
||||
@@ -1015,6 +1070,12 @@ agents:
|
||||
repo: sipeed/picoclaw
|
||||
binary: picoclaw
|
||||
run: picoclaw
|
||||
# PicoClaw gère sa config par sous-commandes (pas de fichier à écrire) :
|
||||
# ces commandes sont affichées après l'install avec {model} substitué.
|
||||
setup_hints:
|
||||
- "picoclaw onboard"
|
||||
- "picoclaw model {model}"
|
||||
- "picoclaw auth login"
|
||||
tags: [go, tiny]
|
||||
|
||||
- name: ironclaw
|
||||
|
||||
@@ -78,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"
|
||||
|
||||
@@ -93,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"
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.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\-h\fR|\fB\-\-help\fR] <\fIPROMPT\fR>
|
||||
\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
|
||||
@@ -21,6 +21,9 @@ Provider of the registry used by aichat (env vars + model)
|
||||
\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
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 1.0.5"
|
||||
.TH am 1 "am 1.1.9"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -136,6 +136,9 @@ Ask a natural\-language request and get the matching am command(s) (issue #78):
|
||||
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
|
||||
@@ -247,4 +250,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v1.0.5
|
||||
v1.1.9
|
||||
|
||||
@@ -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>
|
||||
@@ -111,6 +111,46 @@ pub fn apply_env_map_resolved(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:
|
||||
@@ -124,6 +164,7 @@ pub fn apply_post_install(
|
||||
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() {
|
||||
@@ -136,12 +177,14 @@ pub fn apply_post_install(
|
||||
"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 {
|
||||
@@ -418,6 +461,7 @@ mod tests {
|
||||
provider,
|
||||
model: None,
|
||||
config: Some(config),
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
@@ -515,4 +559,64 @@ mod tests {
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -217,6 +217,12 @@ pub enum Command {
|
||||
/// 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)
|
||||
@@ -723,6 +729,10 @@ pub struct AiArgs {
|
||||
/// 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)]
|
||||
|
||||
+172
-6
@@ -2,24 +2,54 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! shell command through aichat (`--code` — never executed by aichat),
|
||||
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
|
||||
//! default) and only executes after confirmation when required.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::AiArgs;
|
||||
use crate::commands::{require_agent, resolve_exec};
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::shell_ai::{Decision, SafetyMode};
|
||||
use anyhow::{Context, Result};
|
||||
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>"`).
|
||||
// (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() {
|
||||
vec![".".to_string()]
|
||||
context_files(std::path::Path::new("."))
|
||||
} else {
|
||||
args.files
|
||||
.iter()
|
||||
@@ -39,6 +69,115 @@ pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
||||
exec_mode(app, &prompt, &files, args, yes, dry_run)
|
||||
}
|
||||
|
||||
/// True when the aichat binary is available (installed or on PATH).
|
||||
/// Uses a direct PATH lookup — the probe cache would mask a shim added
|
||||
/// mid-test and is meant for agent inventories, not runtime checks.
|
||||
fn aichat_installed(app: &App) -> bool {
|
||||
app.state.get("aichat").ok().flatten().is_some()
|
||||
|| which::which("aichat").is_ok()
|
||||
}
|
||||
|
||||
/// Walk `dir` and collect text files usable as aichat context (the
|
||||
/// default `--files` when the flag is absent). aichat filters nothing
|
||||
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
|
||||
/// - skipped directories: `.git`, vendored/build noise (`target`,
|
||||
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
|
||||
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
|
||||
/// Then the survivors are sorted and taken while a total budget of
|
||||
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
|
||||
/// at 40 files. Empty when nothing qualifies (aichat is called without
|
||||
/// `-f`).
|
||||
fn context_files(dir: &std::path::Path) -> Vec<String> {
|
||||
const MAX_FILES: usize = 40;
|
||||
const MAX_SIZE: u64 = 64 * 1024;
|
||||
const TOTAL_BUDGET: u64 = 128 * 1024;
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
|
||||
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
|
||||
".cargo", "obj", "out", ".svn", ".hg",
|
||||
];
|
||||
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if path.is_dir() {
|
||||
if SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
walk(&path, out);
|
||||
} else {
|
||||
if name.starts_with(".env") {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.len() > MAX_SIZE {
|
||||
continue;
|
||||
}
|
||||
// UTF-8 sniff on the first bytes — aichat rejects binaries.
|
||||
let Ok(mut f) = std::fs::File::open(&path) else { continue };
|
||||
use std::io::Read;
|
||||
let mut head = Vec::new();
|
||||
if f.take(8192).read_to_end(&mut head).is_err()
|
||||
|| std::str::from_utf8(&head).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push((path.display().to_string(), meta.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
walk(dir, &mut found);
|
||||
found.sort();
|
||||
let mut files = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
for (p, len) in found {
|
||||
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
|
||||
break;
|
||||
}
|
||||
total += len;
|
||||
files.push(p);
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// Build the aichat `--agent` arguments for the requested role. Without a
|
||||
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
|
||||
/// definition is missing the roles are regenerated on the fly, and a
|
||||
/// generation failure degrades gracefully (no role). An explicit unknown
|
||||
/// role is an error.
|
||||
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
||||
let role = role.unwrap_or("am-copilot");
|
||||
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
|
||||
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
|
||||
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
|
||||
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
|
||||
let present = || def.exists() || legacy.exists();
|
||||
if present() {
|
||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||
}
|
||||
if role == "am-copilot" {
|
||||
if let Err(e) = crate::roles::generate_all(app) {
|
||||
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
||||
}
|
||||
if present() {
|
||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||
}
|
||||
app.log.warn(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"rôle 'am-copilot' introuvable — lancez am setup --roles",
|
||||
));
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
bail!(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
|
||||
)
|
||||
.replace("{}", role));
|
||||
}
|
||||
|
||||
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
|
||||
/// the provider/model environment applied when requested.
|
||||
fn chat_mode(
|
||||
@@ -50,6 +189,7 @@ fn chat_mode(
|
||||
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());
|
||||
@@ -102,12 +242,16 @@ fn exec_mode(
|
||||
"would generate & classify via aichat (exec) — commande non exécutée"
|
||||
));
|
||||
}
|
||||
// Exec mode asks for the raw command: default to the am-do role
|
||||
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
|
||||
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
|
||||
let cmd = crate::shell_ai::generate(
|
||||
app,
|
||||
prompt,
|
||||
files,
|
||||
args.provider.as_deref(),
|
||||
args.model.as_deref(),
|
||||
&role,
|
||||
)?;
|
||||
let settings = app
|
||||
.config
|
||||
@@ -185,4 +329,26 @@ mod tests {
|
||||
let s = crate::config::ShellAiSettings::default();
|
||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_files_skips_binary_noise_and_secrets() {
|
||||
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(base.join("target")).unwrap();
|
||||
std::fs::create_dir_all(base.join("src")).unwrap();
|
||||
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
|
||||
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
|
||||
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
|
||||
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
|
||||
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
|
||||
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
|
||||
let files = context_files(&base);
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,13 +266,22 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
|
||||
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
|
||||
let rel = Path::new(&bf.path);
|
||||
let target = if bf.path == "config.yaml" {
|
||||
// Config goes to the user config directory of machine B.
|
||||
let dir = crate::config::user_config_dir()
|
||||
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
|
||||
// Config goes to the ACTIVE user config directory of this app
|
||||
// (respects --config / isolated tests) — NOT the machine-global
|
||||
// user config dir: importing must not clobber the real config.
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
dir.join(crate::config::CONFIG_FILE_NAME)
|
||||
} else {
|
||||
state_dir.join(rel)
|
||||
let t = state_dir.join(rel);
|
||||
if !t.starts_with(&state_dir) {
|
||||
anyhow::bail!("security error: bundle contains unsafe path '{}'", bf.path);
|
||||
}
|
||||
t
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -292,8 +301,11 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
|
||||
// confirmed; otherwise it is written next to it as config.migrated.yaml.
|
||||
let cfg_rel = "config.yaml";
|
||||
if restored.iter().any(|r| r == cfg_rel) && !confirm {
|
||||
let dir = crate::config::user_config_dir()
|
||||
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
|
||||
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
|
||||
if imported.exists() {
|
||||
let target = dir.join("config.migrated.yaml");
|
||||
|
||||
@@ -108,6 +108,13 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
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,
|
||||
@@ -312,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)
|
||||
@@ -466,6 +479,7 @@ pub fn select_method<'a>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn routes_cmd_scripts_through_comspec() {
|
||||
@@ -491,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +669,7 @@ mod tests {
|
||||
files: vec![],
|
||||
provider_default: None,
|
||||
}),
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: vec![],
|
||||
installable: false,
|
||||
|
||||
@@ -200,6 +200,7 @@ mod tests {
|
||||
provider: None,
|
||||
model: None,
|
||||
config: None,
|
||||
setup_hints: vec![],
|
||||
sandbox: None,
|
||||
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||
installable: false,
|
||||
|
||||
+11
-1
@@ -110,17 +110,27 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
example: "ask installe claude et lance-le",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "ai <demande> [--exec] [--files <path>]",
|
||||
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)",
|
||||
|
||||
@@ -467,6 +467,12 @@ pub struct AgentDef {
|
||||
/// 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.
|
||||
|
||||
+21
-1
@@ -857,6 +857,25 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "setup",
|
||||
category: "Commands",
|
||||
usage: "setup {flags}",
|
||||
about: "Wizard d'onboarding : provider, token (trousseau OS), modèle par défaut, installation d'aichat et génération des rôles copilot am-* (épique v1.1.0). Ré-exécutable à tout moment (mode revoir).",
|
||||
search_terms: &["wizard", "onboarding", "provider", "token", "setup", "configurer", "premier"],
|
||||
flags: &[
|
||||
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Mode non-interactif : garde les valeurs actuelles (provider/modèle), installe aichat, régénère les rôles" },
|
||||
HelpFlag { short: "", long: "--roles", value: "", desc: "Régénérer uniquement les rôles copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Premier lancement (proposé aussi par am ai / le banner REPL)", code: "am setup" },
|
||||
HelpExample { desc: "Régénérer les rôles après une mise à jour", code: "am setup --roles" },
|
||||
HelpExample { desc: "Non-interactif (scripts)", code: "am setup --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "ask",
|
||||
category: "Commands",
|
||||
@@ -880,13 +899,14 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "ai",
|
||||
category: "Commands",
|
||||
usage: "ai {flags} <prompt...>",
|
||||
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
|
||||
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --code — jamais exécutée par aichat), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
|
||||
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
|
||||
flags: &[
|
||||
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
|
||||
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
|
||||
HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" },
|
||||
HelpFlag { short: "", long: "--role", value: "ROLE", desc: "Rôle copilot aichat (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator — générés par am setup)" },
|
||||
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" },
|
||||
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
|
||||
],
|
||||
|
||||
+13
@@ -169,6 +169,18 @@ pub const CATALOG: &[(&str, &str)] = &[
|
||||
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
|
||||
("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"),
|
||||
("usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai <request> — e.g. ai --exec \"compress the JSON files\""),
|
||||
("am n'est pas configuré — lancez am setup (provider + token)", "am is not configured — run am setup (provider + token)"),
|
||||
("Configuration d'agent-manager (am setup)", "agent-manager configuration (am setup)"),
|
||||
("Le token est stocké dans le trousseau OS — jamais en clair.", "The token is stored in the OS keyring — never in clear."),
|
||||
("Installer le moteur IA (aichat) ?", "Install the AI engine (aichat)?"),
|
||||
("Installation d'aichat…", "Installing aichat…"),
|
||||
("aichat est manquant. Installer ?", "aichat is missing. Install it?"),
|
||||
("installez aichat: am install aichat — puis réessayez", "install aichat: am install aichat — then retry"),
|
||||
("Rôles copilot am-* générés", "am-* copilot roles generated"),
|
||||
("Config aichat créée (provider + modèle)", "aichat config created (provider + model)"),
|
||||
("am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"", "am is configured — try: am ai \"...\" or am ai --exec \"...\""),
|
||||
("rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)", "unknown role '{}' — generate the roles with: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
|
||||
("rôle 'am-copilot' introuvable — lancez am setup --roles", "role 'am-copilot' not found — run am setup --roles"),
|
||||
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
|
||||
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
|
||||
("installation refusée — source non fiable", "installation refused — untrusted source"),
|
||||
@@ -202,6 +214,7 @@ pub const CATALOG: &[(&str, &str)] = &[
|
||||
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"),
|
||||
("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"),
|
||||
("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
|
||||
("Configuration manuelle requise — à exécuter :", "Manual setup required — run:"),
|
||||
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
|
||||
("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"),
|
||||
("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"),
|
||||
|
||||
@@ -98,7 +98,9 @@ pub fn sh(
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<RunOutput> {
|
||||
let out = runner.run(cmd, args, env, cwd, true)?;
|
||||
// Installs are legitimately long (npm/cargo/go/... can take minutes):
|
||||
// no probe timeout here, only run() (doctor/version probes) is bounded.
|
||||
let out = runner.run_untimed(cmd, args, env, cwd, true)?;
|
||||
if out.code != 0 {
|
||||
let mut detail = out.stderr.trim().to_string();
|
||||
if detail.is_empty() {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
pub mod app;
|
||||
pub mod agent_config;
|
||||
pub mod ask;
|
||||
pub mod roles;
|
||||
pub mod setup;
|
||||
pub mod shell_ai;
|
||||
pub mod automation;
|
||||
pub mod backup;
|
||||
|
||||
+23
-14
@@ -30,7 +30,6 @@ impl ExternalInfo {
|
||||
/// Detect the version of an external binary (--version), bounded by a
|
||||
/// timeout so a misbehaving agent can never hang the whole listing.
|
||||
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
|
||||
let mut cmd = std::process::Command::new(&prog);
|
||||
cmd.args(&prefix)
|
||||
@@ -41,6 +40,24 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
let Ok(mut child) = cmd.spawn() else {
|
||||
return None;
|
||||
};
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let mut out = String::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut err = String::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut err);
|
||||
}
|
||||
err
|
||||
});
|
||||
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
|
||||
Ok(Some(s)) => Some(s),
|
||||
@@ -51,22 +68,14 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let Some(status) = status else {
|
||||
return None;
|
||||
};
|
||||
let stdout = stdout_handle.join().unwrap_or_default();
|
||||
let stderr = stderr_handle.join().unwrap_or_default();
|
||||
|
||||
let status = status?;
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut o) = child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut stdout);
|
||||
}
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
let _ = e.read_to_string(&mut stderr);
|
||||
}
|
||||
let combined = format!("{stdout}
|
||||
{stderr}");
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
crate::version::find_version(&combined)
|
||||
}
|
||||
|
||||
|
||||
+13
-8
@@ -17,14 +17,19 @@ pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
|
||||
pub fn is_running(pid: u32) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let out = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.any(|w| w == pid.to_string()),
|
||||
Err(_) => false,
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, STILL_ACTIVE};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if handle == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut code: u32 = 0;
|
||||
let ok = GetExitCodeProcess(handle, &mut code);
|
||||
CloseHandle(handle);
|
||||
ok != 0 && code == STILL_ACTIVE as u32
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
|
||||
+23
-2
@@ -115,6 +115,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("suggest", "recommend an agent for a request"),
|
||||
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
|
||||
("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"),
|
||||
("setup", "onboarding wizard: provider, token, default model, aichat, copilot roles"),
|
||||
("audit", "who changed what, when (config checksums)"),
|
||||
("service", "register an agent as a system service (autostart)"),
|
||||
("schedule", "plan am commands (daily) and check the fleet health"),
|
||||
@@ -240,7 +241,7 @@ impl AmCompleter {
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "registry", "audit",
|
||||
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "setup", "registry", "audit",
|
||||
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
@@ -800,7 +801,7 @@ pub fn banner_box(
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" models models · models --prune · catalog · suggest · ask · ai · audit".to_string(),
|
||||
" models models · models --prune · catalog · suggest · ask · ai · setup · audit".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
|
||||
@@ -1110,6 +1111,14 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
app.log.verbose(&format!("history migration skipped: {e:#}"));
|
||||
}
|
||||
print!("{}", banner(app, &session, &sid));
|
||||
// Onboarding tip (v1.1.0 F1): one discreet line when no provider is
|
||||
// configured yet.
|
||||
if crate::setup::needs_setup(app) {
|
||||
app.log.info(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"am n'est pas configuré — lancez am setup (provider + token)",
|
||||
));
|
||||
}
|
||||
let result = match run_with_editor(app, &mut session, &sid) {
|
||||
Ok(code) => Ok(code),
|
||||
Err(e) => {
|
||||
@@ -1611,6 +1620,7 @@ fn handle_line(
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut provider: Option<String> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut role: Option<String> = None;
|
||||
let mut prompt: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
@@ -1636,6 +1646,12 @@ fn handle_line(
|
||||
model = Some(rest[i].clone());
|
||||
}
|
||||
}
|
||||
"--role" => {
|
||||
i += 1;
|
||||
if i < rest.len() {
|
||||
role = Some(rest[i].clone());
|
||||
}
|
||||
}
|
||||
other => prompt.push(other.to_string()),
|
||||
}
|
||||
i += 1;
|
||||
@@ -1652,10 +1668,14 @@ fn handle_line(
|
||||
files,
|
||||
provider,
|
||||
model,
|
||||
role,
|
||||
yes,
|
||||
dry_run,
|
||||
})
|
||||
},
|
||||
"setup" => Command::Setup {
|
||||
roles: flag("--roles"),
|
||||
},
|
||||
"alias" => match rest.first().map(|s| s.as_str()) {
|
||||
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
|
||||
name: rest[1].clone(),
|
||||
@@ -2073,6 +2093,7 @@ pub fn is_am_command(word: &str) -> bool {
|
||||
| "suggest"
|
||||
| "ask"
|
||||
| "ai"
|
||||
| "setup"
|
||||
| "audit"
|
||||
| "service"
|
||||
| "schedule"
|
||||
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
||||
//!
|
||||
//! Generates aichat agent definitions that turn `am ai` into a
|
||||
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
|
||||
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
|
||||
//! by default, English supported) and instructs the model to use the
|
||||
//! stable `--json` contracts of `am` to inspect the environment.
|
||||
//!
|
||||
//! Two layouts are written for compatibility with every aichat version:
|
||||
//! - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||
//! + `functions/agents.txt` (registry), under the aichat config dir;
|
||||
//! - aichat <= 0.29 : legacy markdown agents `agents/<name>.md`.
|
||||
//!
|
||||
//! The aichat config file is only created when it does not exist yet —
|
||||
//! an existing configuration is never touched (respect of the user setup).
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The six copilot roles shipped by `am setup` / `am setup --roles`.
|
||||
pub const ROLE_NAMES: &[&str] = &[
|
||||
"am-copilot",
|
||||
"am-operator",
|
||||
"am-dev",
|
||||
"am-do",
|
||||
"am-analyst",
|
||||
"am-orchestrator",
|
||||
];
|
||||
|
||||
/// Base config directory of aichat. Candidates are probed in order: an
|
||||
/// existing aichat config wins, otherwise the platform default (XDG on
|
||||
/// unix, %APPDATA% on Windows).
|
||||
pub fn aichat_config_dir() -> PathBuf {
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(xdg).join("aichat"));
|
||||
}
|
||||
if let Some(home) = crate::config::home_dir() {
|
||||
candidates.push(home.join(".config").join("aichat"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
candidates.push(PathBuf::from(appdata).join("aichat"));
|
||||
}
|
||||
}
|
||||
for c in &candidates {
|
||||
if c.exists() {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
// No existing aichat config: platform default.
|
||||
if cfg!(windows) {
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
return PathBuf::from(appdata).join("aichat");
|
||||
}
|
||||
}
|
||||
crate::config::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
.join("aichat")
|
||||
}
|
||||
|
||||
/// Legacy agents directory (aichat <= 0.29): `agents/<name>.md` files
|
||||
/// with frontmatter. aichat >= 0.30 reads the new definitions instead
|
||||
/// (see [`aichat_agent_def_dir`]); the .md files are still written so
|
||||
/// older aichat versions keep working.
|
||||
pub fn aichat_agents_dir() -> PathBuf {
|
||||
aichat_config_dir().join("agents")
|
||||
}
|
||||
|
||||
/// Functions directory (aichat >= 0.30): agent definitions live in
|
||||
/// `functions/agents/<name>/index.yaml` and the agent registry in
|
||||
/// `functions/agents.txt`.
|
||||
pub fn aichat_functions_dir() -> PathBuf {
|
||||
aichat_config_dir().join("functions")
|
||||
}
|
||||
|
||||
/// Directory of one agent definition (aichat >= 0.30).
|
||||
pub fn aichat_agent_def_dir(name: &str) -> PathBuf {
|
||||
aichat_functions_dir().join("agents").join(name)
|
||||
}
|
||||
|
||||
/// Path of the aichat configuration file (created only if absent).
|
||||
pub fn aichat_config_path() -> PathBuf {
|
||||
aichat_config_dir().join("config.yaml")
|
||||
}
|
||||
|
||||
/// The list of `am` commands, embedded in the copilot prompt so the model
|
||||
/// always knows what the CLI can do (kept in sync with `am help commands`).
|
||||
pub fn command_list() -> String {
|
||||
let mut out = String::from(
|
||||
"list, status, sessions, stats, top, report, projects, timeline, log, logs, history, ",
|
||||
);
|
||||
out.push_str(
|
||||
"init, alias, secret, open, watch, search, info, install, uninstall, update, start, stop, ",
|
||||
);
|
||||
out.push_str(
|
||||
"restart, run, doctor, config, completion, self-update, self-uninstall, export, import, ",
|
||||
);
|
||||
out.push_str(
|
||||
"shell, theme, lang, tip, dashboard, favorite, unfavorite, note, tag, untag, tags, ",
|
||||
);
|
||||
out.push_str(
|
||||
"profile, man, models, catalog, providers, suggest, ask, ai, registry, audit, service, ",
|
||||
);
|
||||
out.push_str("schedule, monitor, web, serve, sync, migrate, playbook, lab, plugins, setup");
|
||||
out
|
||||
}
|
||||
|
||||
fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
|
||||
let mut out = String::from("---\n");
|
||||
out.push_str(&format!("name: {name}\n"));
|
||||
out.push_str(&format!("description: {description}\n"));
|
||||
if !tools.is_empty() {
|
||||
out.push_str(&format!("tools: [{}]\n", tools.join(", ")));
|
||||
}
|
||||
out.push_str("---\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The bilingual instruction footer shared by every role.
|
||||
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
|
||||
|
||||
/// The (description, tools, body) of one role.
|
||||
fn role_parts(name: &str) -> Option<(&'static str, &'static [&'static str], String)> {
|
||||
let (desc, tools, body) = match name {
|
||||
"am-copilot" => (
|
||||
"Copilot de agent-manager : guide, catalogue, dépannage",
|
||||
&[][..],
|
||||
format!(
|
||||
"Tu es le copilot de **agent-manager (am)**, le gestionnaire d'agents IA locaux.\n\
|
||||
Tu aides l'utilisateur à exploiter am : catalogue, agents, installation, REPL.\n\
|
||||
\n\
|
||||
Commandes am disponibles : {}\n\
|
||||
\n\
|
||||
Règles :\n\
|
||||
- Privilégie les sorties --json pour inspecter l'environnement : am list --json, am status --json, am info <agent>, am search <mot>.\n\
|
||||
- Pour comprendre l'état : am doctor, am stats --json, am log --kind error.\n\
|
||||
- Propose TOUJOURS la commande exacte à taper, dans un bloc de code.\n\
|
||||
- Si une commande est incertaine, dis-le et propose am help <cmd>.",
|
||||
command_list()
|
||||
),
|
||||
),
|
||||
"am-operator" => (
|
||||
"Opérateur système sécurisé : commandes shell, fichiers, processus",
|
||||
&["shell"][..],
|
||||
"Tu es un opérateur système SÉCURISÉ. Tu transformes les demandes en commandes shell précises.\n\
|
||||
\n\
|
||||
Règles de sécurité (non négociables) :\n\
|
||||
- N'exécute jamais directement : propose la commande, laisse l'utilisateur ou am ai --exec l'exécuter après confirmation.\n\
|
||||
- Signale clairement les commandes RISKY (rm -rf, dd, mkfs, chmod -R 777, git push --force...) et propose une alternative plus sûre quand elle existe.\n\
|
||||
- Adapte-toi à l'OS détecté (Windows/cmd/powershell vs Linux/macOS/bash).\n\
|
||||
- Pour lister/trier/compresser des fichiers, propose la commande la plus simple possible.".to_string(),
|
||||
),
|
||||
"am-dev" => (
|
||||
"Assistant développeur : code, git, tests, revue",
|
||||
&[][..],
|
||||
"Tu es un assistant développeur qui travaille AVEC am (agent-manager).\n\
|
||||
\n\
|
||||
Tu aides sur :\n\
|
||||
- Refactoring, debugging, écriture de tests, revue de code (diff).\n\
|
||||
- Git : am ne gère pas git directement — propose les commandes git exactes.\n\
|
||||
- Suivi de projets : am projects --json, am timeline --json pour le contexte d'activité.\n\
|
||||
- Choix d'agent : am search <tâche> pour recommander un coding agent du catalogue, puis am install / am run.\n\
|
||||
\n\
|
||||
Réponds avec du code concret et des commandes exactes.".to_string(),
|
||||
),
|
||||
"am-do" => (
|
||||
"Exécution pure : génère la commande brute pour am ai --exec",
|
||||
&["shell"][..],
|
||||
"Tu es le moteur d'exécution de am ai --exec.\n\
|
||||
\n\
|
||||
Réponds UNIQUEMENT par la commande shell exacte à exécuter, sur une seule ligne si possible.\n\
|
||||
- Aucune explication avant/après : seulement la commande.\n\
|
||||
- Si la demande est dangereuse, réponds par la commande la plus conservatrice qui satisfait la demande.\n\
|
||||
- Adapte-toi à l'OS détecté.".to_string(),
|
||||
),
|
||||
"am-analyst" => (
|
||||
"Analyste : coûts, logs, statistiques, santé du parc d'agents",
|
||||
&[][..],
|
||||
"Tu es l'analyste de la flotte d'agents gérée par am.\n\
|
||||
\n\
|
||||
Sources de données (toujours en --json) :\n\
|
||||
- Coûts : am stats --costs --json, am top --json\n\
|
||||
- Logs & erreurs : am log --kind error --limit 50 --json, am logs <agent> --lines 50\n\
|
||||
- Activité : am timeline --json, am sessions --json, am report --period week\n\
|
||||
- Santé : am doctor --json, am status --json\n\
|
||||
\n\
|
||||
Produis des synthèses courtes avec les chiffres clés, et propose des actions concrètes.".to_string(),
|
||||
),
|
||||
"am-orchestrator" => (
|
||||
"Orchestrateur : groupes, lab, benchmarks, automatisation",
|
||||
&[][..],
|
||||
"Tu es l'orchestrateur des agents am.\n\
|
||||
\n\
|
||||
Tu aides à :\n\
|
||||
- Lancer des groupes : am start group:dev, am start group:dev --parallel\n\
|
||||
- Benchmarker : am lab --agents a,b --task <fichier> --json\n\
|
||||
- Automatiser : am schedule add <cmd...>, am service install <agent>, am playbook <fichier>\n\
|
||||
- Planifier des runs et interpréter les résultats (am lab --json).\n\
|
||||
\n\
|
||||
Propose toujours des commandes exactes et des benchmarks comparables.".to_string(),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some((desc, tools, body))
|
||||
}
|
||||
|
||||
/// The aichat >= 0.30 agent definition (`index.yaml`): the subset of
|
||||
/// aichat's AgentDefinition that am generates.
|
||||
#[derive(serde::Serialize)]
|
||||
struct AgentDefinition {
|
||||
name: String,
|
||||
description: String,
|
||||
version: String,
|
||||
instructions: String,
|
||||
}
|
||||
|
||||
/// Build the legacy markdown content of one role (aichat <= 0.29).
|
||||
pub fn role_content(name: &str) -> Option<String> {
|
||||
let (desc, tools, body) = role_parts(name)?;
|
||||
Some(format!(
|
||||
"{}{}{}",
|
||||
frontmatter(name, desc, tools),
|
||||
body,
|
||||
LANG_FOOTER
|
||||
))
|
||||
}
|
||||
|
||||
/// The aichat >= 0.30 `index.yaml` definition of one role.
|
||||
pub fn role_definition(name: &str) -> Option<String> {
|
||||
let (desc, _tools, body) = role_parts(name)?;
|
||||
let def = AgentDefinition {
|
||||
name: name.to_string(),
|
||||
description: desc.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
instructions: format!("{body}{LANG_FOOTER}"),
|
||||
};
|
||||
serde_yaml::to_string(&def).ok()
|
||||
}
|
||||
|
||||
/// Write every am-* role into the aichat agent stores (creates the
|
||||
/// directories when missing). Two layouts are written so any aichat
|
||||
/// version works:
|
||||
/// - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||
/// + `functions/agents.txt` (registry);
|
||||
/// - aichat <= 0.29 : legacy markdown `agents/<name>.md`.
|
||||
/// Existing files with the same name are overwritten — they are generated
|
||||
/// artifacts of am.
|
||||
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
||||
let mut written = Vec::new();
|
||||
|
||||
// New layout (aichat >= 0.30): index.yaml definitions + agents.txt.
|
||||
let funcs_dir = aichat_functions_dir();
|
||||
let agents_dir = funcs_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
for name in ROLE_NAMES {
|
||||
let def = role_definition(name).expect("known role");
|
||||
let def_path = agents_dir.join(name).join("index.yaml");
|
||||
std::fs::create_dir_all(def_path.parent().expect("parent dir"))?;
|
||||
std::fs::write(&def_path, def)?;
|
||||
written.push(def_path);
|
||||
}
|
||||
let list_path = funcs_dir.join("agents.txt");
|
||||
let mut list = String::from("# generated by `am setup` — aichat >= 0.30 agent registry\n");
|
||||
list.push_str(&format!("{}\n", ROLE_NAMES.join("\n")));
|
||||
std::fs::write(&list_path, list)?;
|
||||
written.push(list_path);
|
||||
|
||||
// Legacy layout (aichat <= 0.29): markdown agents in agents/.
|
||||
let legacy_dir = aichat_agents_dir();
|
||||
std::fs::create_dir_all(&legacy_dir)?;
|
||||
for name in ROLE_NAMES {
|
||||
let content = role_content(name).expect("known role");
|
||||
let path = legacy_dir.join(format!("{name}.md"));
|
||||
std::fs::write(&path, content)?;
|
||||
written.push(path);
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Create the aichat config file (provider + default model) ONLY when it
|
||||
/// does not exist. An existing configuration is left untouched.
|
||||
pub fn ensure_config_file(app: &App) -> Result<bool> {
|
||||
let path = aichat_config_path();
|
||||
if path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
let provider = crate::providers::default_name(&app.config);
|
||||
let model = provider.and_then(|p| crate::providers::get(&app.config, p))
|
||||
.and_then(|def| def.default_model.clone());
|
||||
let mut body = String::from("# generated by `am setup` — first run only\n");
|
||||
if let Some(p) = provider {
|
||||
body.push_str(&format!("model_provider: {p}\n"));
|
||||
}
|
||||
if let Some(m) = model {
|
||||
body.push_str(&format!("model: {m}\n"));
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, body)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generates_all_six_roles_with_frontmatter() {
|
||||
for name in ROLE_NAMES {
|
||||
let content = role_content(name).expect("role");
|
||||
assert!(content.starts_with("---\n"), "{name}");
|
||||
assert!(content.contains(&format!("name: {name}")), "{name}");
|
||||
assert!(content.contains("description:"), "{name}");
|
||||
// Bilingual footer present.
|
||||
assert!(content.contains("Réponds en français"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tools_only_on_execution_roles() {
|
||||
let operator = role_content("am-operator").unwrap();
|
||||
assert!(operator.contains("tools: [shell]"));
|
||||
let copilot = role_content("am-copilot").unwrap();
|
||||
assert!(!copilot.contains("tools:"), "copilot must not have tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_role_returns_none() {
|
||||
assert!(role_content("am-inconnu").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copilot_prompt_embeds_the_command_list() {
|
||||
let copilot = role_content("am-copilot").unwrap();
|
||||
assert!(copilot.contains("am list --json"));
|
||||
assert!(copilot.contains("setup"), "command list must include setup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_definition_is_valid_index_yaml() {
|
||||
for name in ROLE_NAMES {
|
||||
let def = role_definition(name).expect("role");
|
||||
assert!(def.starts_with("name:"), "{name}:\n{def}");
|
||||
assert!(def.contains("description:"), "{name}");
|
||||
assert!(def.contains("instructions:"), "{name}");
|
||||
assert!(def.contains("Tu es"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_def_dir_targets_functions_layout() {
|
||||
let s = aichat_agent_def_dir("am-copilot")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert!(s.contains("functions"), "{s}");
|
||||
assert!(s.ends_with("am-copilot"), "{s}");
|
||||
// The registry lives next to the definitions.
|
||||
let registry = aichat_functions_dir().join("agents.txt");
|
||||
assert!(registry.to_string_lossy().ends_with("agents.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_dir_prefers_existing_aichat_config() {
|
||||
// With a home dir, the unix candidate is ~/.config/aichat/agents.
|
||||
let dir = aichat_agents_dir();
|
||||
assert!(dir.ends_with("agents"), "{dir:?}");
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -52,6 +52,20 @@ pub trait Runner {
|
||||
capture: bool,
|
||||
) -> Result<RunOutput>;
|
||||
|
||||
/// Run without the probe timeout: used for legitimately long
|
||||
/// operations (package installs, git clones, ...). The default
|
||||
/// implementation delegates to run().
|
||||
fn run_untimed(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run(cmd, args, env, cwd, capture)
|
||||
}
|
||||
|
||||
/// Locate a binary on the PATH (like the which command).
|
||||
fn which(&self, bin: &str) -> Option<PathBuf>;
|
||||
}
|
||||
@@ -73,14 +87,17 @@ impl<'a> SystemRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Runner for SystemRunner<'a> {
|
||||
fn run(
|
||||
impl<'a> SystemRunner<'a> {
|
||||
/// Shared implementation. The timeout applies only to captured
|
||||
/// probes; long-running installs pass None via run_untimed().
|
||||
fn run_inner(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
timeout: Option<std::time::Duration>,
|
||||
) -> Result<RunOutput> {
|
||||
let display = if args.is_empty() {
|
||||
cmd.to_string()
|
||||
@@ -102,12 +119,70 @@ impl<'a> Runner for SystemRunner<'a> {
|
||||
c.current_dir(dir);
|
||||
}
|
||||
if capture {
|
||||
let out = c
|
||||
.output()
|
||||
c.stdout(std::process::Stdio::piped());
|
||||
c.stderr(std::process::Stdio::piped());
|
||||
let mut child = c
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to execute: {display}"))?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let mut out = Vec::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = std::io::Read::read_to_end(&mut pipe, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut err = Vec::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = std::io::Read::read_to_end(&mut pipe, &mut err);
|
||||
}
|
||||
err
|
||||
});
|
||||
|
||||
let status = if let Some(timeout) = timeout {
|
||||
use wait_timeout::ChildExt;
|
||||
match child.wait_timeout(timeout) {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
self.log
|
||||
.warn(format!("command timed out after {timeout:?}: {display}"));
|
||||
let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default())
|
||||
.to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default())
|
||||
.to_string();
|
||||
return Ok(RunOutput {
|
||||
code: -1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok(RunOutput {
|
||||
code: -1,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
child
|
||||
.wait()
|
||||
.with_context(|| format!("failed to wait: {display}"))?
|
||||
};
|
||||
|
||||
let stdout_bytes = stdout_handle.join().unwrap_or_default();
|
||||
let stderr_bytes = stderr_handle.join().unwrap_or_default();
|
||||
let code = status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
|
||||
self.log.result(code, &stderr);
|
||||
Ok(RunOutput {
|
||||
code,
|
||||
@@ -126,6 +201,42 @@ impl<'a> Runner for SystemRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<'a> Runner for SystemRunner<'a> {
|
||||
/// Probes are bounded by a 30 s timeout so a misbehaving process can
|
||||
/// never hang doctor/version detection.
|
||||
fn run(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run_inner(
|
||||
cmd,
|
||||
args,
|
||||
env,
|
||||
cwd,
|
||||
capture,
|
||||
Some(std::time::Duration::from_secs(30)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Installs and other legitimately long operations run without the
|
||||
/// probe timeout (npm/cargo/go installs can take minutes).
|
||||
fn run_untimed(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run_inner(cmd, args, env, cwd, capture, None)
|
||||
}
|
||||
|
||||
fn which(&self, bin: &str) -> Option<PathBuf> {
|
||||
which::which(bin).ok()
|
||||
}
|
||||
|
||||
+8
-9
@@ -103,10 +103,9 @@ mod tests {
|
||||
use super::*;
|
||||
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 dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
@@ -116,7 +115,7 @@ mod tests {
|
||||
)
|
||||
.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 {
|
||||
@@ -125,7 +124,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn no_profile_means_unsandboxed() {
|
||||
let app = test_app("");
|
||||
let (_guard, app) = test_app("");
|
||||
assert!(profile_of(agent(&app)).is_none());
|
||||
let mut env = BTreeMap::new();
|
||||
// No profile: everything allowed, nothing refused.
|
||||
@@ -135,7 +134,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
@@ -161,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn allowed_command_and_cwd_pass() {
|
||||
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",
|
||||
cwd.display().to_string().replace('\\', "\\\\")
|
||||
));
|
||||
@@ -172,7 +171,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
@@ -182,7 +181,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
@@ -194,7 +193,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
|
||||
+13
-3
@@ -25,8 +25,9 @@ impl SecretStore for KeyringStore {
|
||||
if !idx.iter().any(|k| k == key) {
|
||||
idx.push(key.to_string());
|
||||
}
|
||||
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&idx.join(","));
|
||||
let _ = index.set_password(&json_idx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,8 +52,9 @@ impl SecretStore for KeyringStore {
|
||||
let mut idx = self.list().unwrap_or_default();
|
||||
if idx.iter().any(|k| k == key) {
|
||||
idx.retain(|k| k != key);
|
||||
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&idx.join(","));
|
||||
let _ = index.set_password(&json_idx);
|
||||
}
|
||||
Ok(existed)
|
||||
}
|
||||
@@ -60,7 +62,15 @@ impl SecretStore for KeyringStore {
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
match index.get_password() {
|
||||
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
|
||||
Ok(v) => {
|
||||
if let Ok(vec) = serde_json::from_str::<Vec<String>>(&v) {
|
||||
Ok(vec)
|
||||
} else if v.contains('\0') {
|
||||
Ok(v.split('\0').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||
} else {
|
||||
Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||
}
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
}
|
||||
|
||||
+11
-2
@@ -370,9 +370,18 @@ fn ws_accept(key: &str) -> String {
|
||||
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<()> {
|
||||
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.flush()?;
|
||||
Ok(())
|
||||
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
//! am setup — the onboarding wizard (épique v1.1.0, F1).
|
||||
//!
|
||||
//! Guides the user through: provider choice, masked API-token entry (OS
|
||||
//! keyring, never in clear), default model selection, a non-blocking API
|
||||
//! ping, the optional aichat installation (F2) and the generation of the
|
||||
//! am-* copilot roles for aichat (F3). Re-runnable at any time (`am setup`
|
||||
//! = "review" mode); triggered on first run by the AI commands and the
|
||||
//! REPL banner when no provider is configured.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::ProvidersCmd;
|
||||
use crate::commands::{config_cmd, install_cmd, providers_cmd};
|
||||
use crate::secrets::SecretStore;
|
||||
use anyhow::{bail, Result};
|
||||
use std::io::Write;
|
||||
|
||||
/// Providers offered by the wizard: every provider defined in the merged
|
||||
/// registry (embedded + user config, sorted), plus the local Ollama runtime
|
||||
/// and a custom endpoint. Built dynamically so new registry providers
|
||||
/// (e.g. the QwenCloud token-plan / pay-as-you-go pair) show up without a
|
||||
/// code change — CHOICES used to be a hardcoded list that silently drifted.
|
||||
fn choices(app: &App) -> Vec<String> {
|
||||
let mut v: Vec<String> = crate::providers::defined(&app.config)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
v.sort();
|
||||
if !v.iter().any(|p| p == "ollama") {
|
||||
v.push("ollama".to_string());
|
||||
}
|
||||
v.push("custom".to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// True when the user has not configured a usable default provider yet:
|
||||
/// no `settings.default_provider`, or the default provider has no API
|
||||
/// token in the keyring (except local Ollama, which needs none).
|
||||
pub fn needs_setup(app: &App) -> bool {
|
||||
let Some(def) = crate::providers::default_name(&app.config) else {
|
||||
return true;
|
||||
};
|
||||
if def == "ollama" {
|
||||
return false;
|
||||
}
|
||||
let store = crate::secrets::store();
|
||||
store
|
||||
.get(&crate::secrets::key_for_provider_token(def))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
}
|
||||
|
||||
/// Read one trimmed line from stdin. `None` on EOF — callers must not spin
|
||||
/// on empty input when nothing is connected (pipes, --yes in CI).
|
||||
fn read_line() -> Result<Option<String>> {
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line)? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(line.trim().to_string()))
|
||||
}
|
||||
|
||||
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
|
||||
fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
|
||||
if app.cli.yes {
|
||||
return Ok(true);
|
||||
}
|
||||
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
|
||||
eprint!("{question} {suffix} ");
|
||||
std::io::stderr().flush()?;
|
||||
let answer = read_line()?.unwrap_or_default().to_lowercase();
|
||||
Ok(match answer.as_str() {
|
||||
"" => default_yes,
|
||||
"y" | "yes" | "o" | "oui" | "true" => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick a provider from the list (or the current default when --yes).
|
||||
fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
|
||||
let current = crate::providers::default_name(&app.config);
|
||||
let choices = choices(app);
|
||||
println!("Provider disponible :");
|
||||
for (i, p) in choices.iter().enumerate() {
|
||||
let star = if Some(p.as_str()) == current { " (actuel)" } else { "" };
|
||||
println!(" {}) {}{star}", i + 1, p);
|
||||
}
|
||||
if app.cli.yes {
|
||||
let name = current.unwrap_or("anthropic").to_string();
|
||||
return Ok((name, None));
|
||||
}
|
||||
loop {
|
||||
print!("Choisissez (1-{}) : ", choices.len());
|
||||
std::io::stdout().flush()?;
|
||||
let Some(raw) = read_line()? else {
|
||||
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
|
||||
};
|
||||
if let Ok(n) = raw.parse::<usize>() {
|
||||
if (1..=choices.len()).contains(&n) {
|
||||
let name = &choices[n - 1];
|
||||
if name == "custom" {
|
||||
print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
|
||||
std::io::stdout().flush()?;
|
||||
let url = read_line()?.unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return Ok(("custom".to_string(), Some(url)));
|
||||
}
|
||||
return Ok((name.to_string(), None));
|
||||
}
|
||||
}
|
||||
if !raw.is_empty() {
|
||||
return Ok((raw, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The declared models of a provider (embedded registry), or an empty list
|
||||
/// for local/custom providers.
|
||||
fn declared_models(app: &App, name: &str) -> Vec<String> {
|
||||
crate::providers::get(&app.config, name)
|
||||
.map(|d| d.models.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the default model: numbered list of the declared models plus a
|
||||
/// free entry. `--yes` keeps the provider's current default.
|
||||
fn pick_model(app: &App, name: &str) -> Result<String> {
|
||||
let models = declared_models(app, name);
|
||||
let current = crate::providers::get(&app.config, name)
|
||||
.and_then(|d| d.default_model.clone())
|
||||
.or_else(|| models.first().cloned());
|
||||
if !models.is_empty() {
|
||||
println!("Modèles disponibles pour {name} :");
|
||||
for (i, m) in models.iter().enumerate() {
|
||||
let star = if Some(m) == current.as_ref() { " (défaut)" } else { "" };
|
||||
println!(" {}) {m}{star}", i + 1);
|
||||
}
|
||||
println!(" {}) autre modèle…", models.len() + 1);
|
||||
} else {
|
||||
println!("Aucun modèle déclaré pour {name} — saisissez le modèle par défaut.");
|
||||
}
|
||||
if app.cli.yes {
|
||||
return Ok(current.unwrap_or_else(|| "gpt-4o".to_string()));
|
||||
}
|
||||
loop {
|
||||
print!("Modèle par défaut : ");
|
||||
std::io::stdout().flush()?;
|
||||
let Some(raw) = read_line()? else {
|
||||
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
|
||||
};
|
||||
if let Ok(n) = raw.parse::<usize>() {
|
||||
if n >= 1 && n <= models.len() {
|
||||
return Ok(models[n - 1].clone());
|
||||
}
|
||||
if n == models.len() + 1 {
|
||||
print!("Nom du modèle : ");
|
||||
std::io::stdout().flush()?;
|
||||
let custom = read_line()?.unwrap_or_default();
|
||||
if !custom.is_empty() {
|
||||
return Ok(custom);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !raw.is_empty() {
|
||||
return Ok(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking API ping: any HTTP answer (200, 401…) proves the endpoint
|
||||
/// is reachable and the token is transmitted. Network failures are logged
|
||||
/// as warnings only — the wizard never blocks on this.
|
||||
fn ping_provider(name: &str, base_url: &str, token: Option<&str>) {
|
||||
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||
let result = match token {
|
||||
Some(t) => ureq::get(&url).set("Authorization", &format!("Bearer {t}")).call(),
|
||||
None => ureq::get(&url).call(),
|
||||
};
|
||||
match result {
|
||||
Ok(_) | Err(ureq::Error::Status(_, _)) => {
|
||||
// Reachable (200 or an auth-status): the endpoint answered.
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠ ping du provider échoué (non bloquant) : {e}");
|
||||
}
|
||||
}
|
||||
let _ = name;
|
||||
}
|
||||
|
||||
/// Main wizard. `--yes` runs it non-interactively with the current
|
||||
/// defaults (install aichat + roles included).
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
let lang = app.lang();
|
||||
if !app.cli.yes {
|
||||
println!("⚙ {}", crate::i18n::tr_in(lang, "Configuration d'agent-manager (am setup)"));
|
||||
println!("{}", crate::i18n::tr_in(lang, "Le token est stocké dans le trousseau OS — jamais en clair."));
|
||||
}
|
||||
|
||||
// 1) Provider.
|
||||
let (provider, custom_url) = pick_provider(app)?;
|
||||
let base_url = if provider == "custom" {
|
||||
custom_url.clone().unwrap_or_default()
|
||||
} else {
|
||||
crate::providers::get(&app.config, &provider)
|
||||
.map(|d| d.base_url.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if provider == "custom" {
|
||||
let cmd = ProvidersCmd::Add {
|
||||
name: "custom".to_string(),
|
||||
base_url: custom_url.clone().unwrap_or_default(),
|
||||
model: None,
|
||||
models: None,
|
||||
};
|
||||
providers_cmd::run(app, Some(&cmd))?;
|
||||
} else if provider == "ollama" {
|
||||
// Local runtime: register it (it is not part of the embedded
|
||||
// registry) so the config stays valid, then keep going tokenless.
|
||||
let cmd = ProvidersCmd::Add {
|
||||
name: "ollama".to_string(),
|
||||
base_url: "http://localhost:11434/v1".to_string(),
|
||||
model: None,
|
||||
models: None,
|
||||
};
|
||||
providers_cmd::run(app, Some(&cmd))?;
|
||||
}
|
||||
|
||||
// 2) Token (masked) — skipped for local Ollama.
|
||||
let mut token: Option<String> = None;
|
||||
if provider != "ollama" {
|
||||
if app.cli.yes {
|
||||
let store = crate::secrets::store();
|
||||
token = store
|
||||
.get(&crate::secrets::key_for_provider_token(&provider))
|
||||
.ok()
|
||||
.flatten();
|
||||
} else {
|
||||
let value = rpassword::prompt_password(&format!(
|
||||
"Token API pour {provider} (invisible) : "
|
||||
))?;
|
||||
if !value.is_empty() {
|
||||
token = Some(value.clone());
|
||||
let cmd = ProvidersCmd::SetToken {
|
||||
name: provider.clone(),
|
||||
value,
|
||||
};
|
||||
providers_cmd::run(app, Some(&cmd))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Default model. The registry lives under settings.providers — a
|
||||
// top-level `providers:` key is rejected by the schema validator, and a
|
||||
// provider block written to the user config requires base_url too.
|
||||
let model = pick_model(app, &provider)?;
|
||||
if provider == "ollama" {
|
||||
// base_url was already written by the Add above.
|
||||
config_cmd::persist_setting(app, "settings.providers.ollama.default_model", &model)?;
|
||||
} else if provider != "custom" {
|
||||
if let Some(def) = crate::providers::get(&app.config, &provider) {
|
||||
config_cmd::persist_setting(
|
||||
app,
|
||||
&format!("settings.providers.{provider}.base_url"),
|
||||
&def.base_url,
|
||||
)?;
|
||||
config_cmd::persist_setting(
|
||||
app,
|
||||
&format!("settings.providers.{provider}.default_model"),
|
||||
&model,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Default provider.
|
||||
config_cmd::persist_setting(app, "settings.default_provider", &provider)?;
|
||||
|
||||
// 5) Non-blocking ping.
|
||||
if provider != "ollama" && !base_url.is_empty() {
|
||||
ping_provider(&provider, &base_url, token.as_deref());
|
||||
}
|
||||
|
||||
// 6) Install aichat (F2) — skipped when already present.
|
||||
let aichat_present = app
|
||||
.state
|
||||
.get("aichat")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| which::which("aichat").is_ok();
|
||||
if !aichat_present {
|
||||
if ask(app, crate::i18n::tr_in(lang, "Installer le moteur IA (aichat) ?"), true)? {
|
||||
println!("{}", crate::i18n::tr_in(lang, "Installation d'aichat…"));
|
||||
install_cmd::run(app, "aichat", None, false, None, None, false)?;
|
||||
}
|
||||
} else {
|
||||
println!("✓ aichat déjà installé");
|
||||
}
|
||||
|
||||
// 7) Copilot roles (F3) + aichat config (only when absent).
|
||||
let roles = crate::roles::generate_all(app)?;
|
||||
let config_created = crate::roles::ensure_config_file(app)?;
|
||||
println!(
|
||||
"✓ {} ({} fichiers)",
|
||||
crate::i18n::tr_in(lang, "Rôles copilot am-* générés"),
|
||||
roles.len()
|
||||
);
|
||||
if config_created {
|
||||
println!("✓ {}", crate::i18n::tr_in(lang, "Config aichat créée (provider + modèle)"));
|
||||
}
|
||||
|
||||
app.log.success(&crate::i18n::tr_in(
|
||||
lang,
|
||||
"am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"",
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// `am setup --roles`: (re)generate only the copilot roles.
|
||||
pub fn run_roles(app: &App) -> Result<i32> {
|
||||
let roles = crate::roles::generate_all(app)?;
|
||||
println!("✓ {} fichiers de rôle :", roles.len());
|
||||
for p in &roles {
|
||||
println!(" {}", p.display());
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use crate::cli::Cli;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn app_with(settings_yaml: &str) -> App {
|
||||
app_with_path(settings_yaml).0
|
||||
}
|
||||
|
||||
fn app_with_path(settings_yaml: &str) -> (App, PathBuf) {
|
||||
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"am-setup-test-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{settings_yaml}agents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
(App::from_cli(cli).unwrap(), cfg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_setup_true_without_a_configured_provider() {
|
||||
// The embedded default ships default_provider: anthropic, but no
|
||||
// keyring token exists in a test process -> setup needed.
|
||||
let app = app_with("");
|
||||
assert!(needs_setup(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_offers_every_registry_provider() {
|
||||
// Regression (v1.1.8): the wizard used a hardcoded CHOICES list and
|
||||
// the QwenCloud providers added to the registry never appeared.
|
||||
// The list must now track the merged registry + ollama + custom.
|
||||
let app = app_with("");
|
||||
let c = choices(&app);
|
||||
for p in [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"deepseek",
|
||||
"google",
|
||||
"openrouter",
|
||||
"qwen-cloud",
|
||||
"qwen-cloud-token-plan",
|
||||
"ollama",
|
||||
"custom",
|
||||
] {
|
||||
assert!(c.iter().any(|x| x == p), "wizard must offer {p}: {c:?}");
|
||||
}
|
||||
// User-defined providers show up too, ollama is never duplicated.
|
||||
let app2 = app_with(
|
||||
" default_provider: anthropic\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n my-llm:\n base_url: https://llm.internal/v1\n",
|
||||
);
|
||||
let c2 = choices(&app2);
|
||||
assert_eq!(c2.iter().filter(|x| *x == "ollama").count(), 1, "{c2:?}");
|
||||
assert!(c2.iter().any(|x| x == "my-llm"), "{c2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_setup_false_for_local_ollama() {
|
||||
let app = app_with(
|
||||
" default_provider: ollama\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n",
|
||||
);
|
||||
assert!(!needs_setup(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_persists_under_settings_providers() {
|
||||
// Regression: a top-level `providers:` key is rejected by the
|
||||
// schema validator ("unknown field `providers`"). The wizard must
|
||||
// write settings.providers.<name>.default_model.
|
||||
let (app, cfg) = app_with_path("");
|
||||
config_cmd::persist_setting(
|
||||
&app,
|
||||
"settings.providers.deepseek.base_url",
|
||||
"https://api.deepseek.com",
|
||||
)
|
||||
.unwrap();
|
||||
config_cmd::persist_setting(
|
||||
&app,
|
||||
"settings.providers.deepseek.default_model",
|
||||
"deepseek-reasoner",
|
||||
)
|
||||
.unwrap();
|
||||
config_cmd::persist_setting(&app, "settings.default_provider", "deepseek").unwrap();
|
||||
|
||||
// Reloading the config must succeed (validation passes) and the
|
||||
// merged registry must reflect the new default model.
|
||||
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let app2 = App::from_cli(cli).expect("config must stay valid after setup writes");
|
||||
let def = crate::providers::get(&app2.config, "deepseek").unwrap();
|
||||
assert_eq!(def.default_model.as_deref(), Some("deepseek-reasoner"));
|
||||
assert_eq!(
|
||||
crate::providers::default_name(&app2.config).as_deref(),
|
||||
Some("deepseek")
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -7,8 +7,9 @@
|
||||
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
|
||||
//! spawned in the foreground, exactly as the user would run it;
|
||||
//! - exec mode (`--exec`): aichat is asked to generate the command with
|
||||
//! `--dry-run` (it prints the command and never runs it), then the
|
||||
//! generated command is classified (safe / risky), a confidence score
|
||||
//! `--code` (it prints only the command and never runs it — aichat is
|
||||
//! invoked without `--execute`), then the generated command is
|
||||
//! classified (safe / risky), a confidence score
|
||||
//! is estimated, the configured safety policy is applied (dry-run by
|
||||
//! default), and only then — after confirmation when required — is the
|
||||
//! command executed through the user's shell.
|
||||
@@ -280,9 +281,17 @@ pub fn generate(
|
||||
files: &[String],
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
role_args: &[String],
|
||||
) -> Result<String> {
|
||||
let agent = require_agent(app, "aichat")?;
|
||||
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
|
||||
// aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to
|
||||
// skip the API call entirely (it echoes the request), so the old
|
||||
// `--execute --dry-run` no longer yields the generated command. Plain
|
||||
// chat with `--code` extracts and prints only the command (same output
|
||||
// shape, works on every aichat version). aichat NEVER executes here —
|
||||
// am enforces the safety policy before running the command itself.
|
||||
let mut extra_args: Vec<String> = vec!["--code".to_string()];
|
||||
extra_args.extend(role_args.iter().cloned());
|
||||
for f in files {
|
||||
extra_args.push("-f".to_string());
|
||||
extra_args.push(f.clone());
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ const WEB: &str = r#"# Généré par 'am init --template web' — projet {projec
|
||||
# Répertoire : {dir}
|
||||
version: "1.0"
|
||||
groups:
|
||||
dev: [claude-code, opencode, gemini-cli]
|
||||
dev: [claude-code, opencode, antigravity-cli]
|
||||
profiles:
|
||||
dev:
|
||||
agent: claude-code
|
||||
|
||||
+4
-1
@@ -134,7 +134,9 @@ fn index_page(app: &App) -> HttpResult {
|
||||
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
|
||||
// Then every {{label}} token: replaced by the localized label (the
|
||||
// French text is both the key and the fallback, so nothing survives).
|
||||
while let Some(start) = html.find("{{") {
|
||||
let mut cursor = 0;
|
||||
while let Some(rel_start) = html[cursor..].find("{{") {
|
||||
let start = cursor + rel_start;
|
||||
let Some(rel_end) = html[start + 2..].find("}}") else {
|
||||
break;
|
||||
};
|
||||
@@ -142,6 +144,7 @@ fn index_page(app: &App) -> HttpResult {
|
||||
let key = html[start + 2..end].to_string();
|
||||
let translated = crate::i18n::tr_in(lang, &key);
|
||||
html.replace_range(start..end + 2, translated);
|
||||
cursor = start + translated.len();
|
||||
}
|
||||
HttpResult {
|
||||
status: 200,
|
||||
|
||||
Reference in New Issue
Block a user