Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068940495a | ||
|
|
d71c3dd34d |
Generated
+23
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.0.6"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -36,6 +36,7 @@ dependencies = [
|
|||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reedline",
|
"reedline",
|
||||||
|
"rpassword",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1779,6 +1780,27 @@ version = "1.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
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]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.0.6"
|
version = "1.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -38,6 +38,7 @@ toml = "0.8"
|
|||||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||||
wait-timeout = "0.2"
|
wait-timeout = "0.2"
|
||||||
keyring = { version = "3", features = ["windows-native", "linux-native"] }
|
keyring = { version = "3", features = ["windows-native", "linux-native"] }
|
||||||
|
rpassword = "7"
|
||||||
which = "7"
|
which = "7"
|
||||||
zip = "0.6"
|
zip = "0.6"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
|
|||||||
@@ -48,6 +48,32 @@ plateforme, il compile automatiquement depuis les sources.
|
|||||||
|
|
||||||
> 🔄 **Mise à jour** : relancez simplement la même commande.
|
> 🔄 **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
|
## 🎬 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 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 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 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 info <agent> | fiche détaillée (installation, dépendances, site…) |
|
||||||
| am status [agent] | état, version, PID, logs |
|
| am status [agent] | état, version, PID, logs |
|
||||||
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
|
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
|
||||||
|
|||||||
+32
-2
@@ -1,7 +1,7 @@
|
|||||||
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
# 🗺️ ROADMAP agent-manager — vers le « super outil »
|
||||||
|
|
||||||
> **Version du document : 2.6** · Statut : phases 0 à 3 livrées
|
> **Version du document : 3.0** · Statut : phases 0 à 3 livrées
|
||||||
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.0.6
|
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.0
|
||||||
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
|
||||||
>
|
>
|
||||||
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
|
||||||
@@ -55,6 +55,21 @@
|
|||||||
> Le script accepte désormais les deux noms ; archives v1.0.6 publiées avec
|
> 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
|
> les deux entrées pour la compatibilité maximale. Chemin du message
|
||||||
> cargo install corrigé (`.cargo\bin\am.exe`).
|
> 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -456,6 +471,21 @@ 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 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 |
|
| `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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ if (-not $InstallDir) {
|
|||||||
$env:Path = "$binDir;$env:Path"
|
$env:Path = "$binDir;$env:Path"
|
||||||
& (Join-Path $binDir "am.exe") --version
|
& (Join-Path $binDir "am.exe") --version
|
||||||
Write-Host ""
|
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 "OK - 'am' est installe. Essayez :"
|
||||||
Write-Host " am list"
|
Write-Host " am list"
|
||||||
Write-Host " am doctor"
|
Write-Host " am doctor"
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ esac
|
|||||||
|
|
||||||
"$INSTALL_DIR/am" --version || true
|
"$INSTALL_DIR/am" --version || true
|
||||||
echo ""
|
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 "OK — 'am' est installé. Essayez :"
|
||||||
echo " am list"
|
echo " am list"
|
||||||
echo " am doctor"
|
echo " am doctor"
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,7 @@
|
|||||||
.SH NAME
|
.SH NAME
|
||||||
ai \- Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
ai \- Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||||
.SH SYNOPSIS
|
.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
|
.SH DESCRIPTION
|
||||||
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
@@ -21,6 +21,9 @@ Provider of the registry used by aichat (env vars + model)
|
|||||||
\fB\-\-model\fR \fI<MODEL>\fR
|
\fB\-\-model\fR \fI<MODEL>\fR
|
||||||
Force a model (aichat \-\-model)
|
Force a model (aichat \-\-model)
|
||||||
.TP
|
.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
|
\fB\-h\fR, \fB\-\-help\fR
|
||||||
Print help
|
Print help
|
||||||
.TP
|
.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
|
.ie \n(.g .ds Aq \(aq
|
||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am 1 "am 1.0.5"
|
.TH am 1 "am 1.1.0"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
am \- agent\-manager (am) — manage local AI coding agents
|
am \- agent\-manager (am) — manage local AI coding agents
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -136,6 +136,9 @@ Ask a natural\-language request and get the matching am command(s) (issue #78):
|
|||||||
am\-ai(1)
|
am\-ai(1)
|
||||||
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||||
.TP
|
.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)
|
am\-start(1)
|
||||||
Start an agent (foreground by default, or detached with \-\-background)
|
Start an agent (foreground by default, or detached with \-\-background)
|
||||||
.TP
|
.TP
|
||||||
@@ -247,4 +250,4 @@ Export the configuration and installation state (backup)
|
|||||||
am\-import(1)
|
am\-import(1)
|
||||||
Import a previously exported configuration and state
|
Import a previously exported configuration and state
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
v1.0.5
|
v1.1.0
|
||||||
|
|||||||
+10
@@ -217,6 +217,12 @@ pub enum Command {
|
|||||||
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
|
||||||
#[command(alias = "shell")]
|
#[command(alias = "shell")]
|
||||||
Ai(AiArgs),
|
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 an agent (foreground by default, or detached with --background)
|
||||||
Start(StartArgs),
|
Start(StartArgs),
|
||||||
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
|
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
|
||||||
@@ -723,6 +729,10 @@ pub struct AiArgs {
|
|||||||
/// Force a model (aichat --model)
|
/// Force a model (aichat --model)
|
||||||
#[arg(long, value_name = "MODEL")]
|
#[arg(long, value_name = "MODEL")]
|
||||||
pub model: Option<String>,
|
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
|
/// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is
|
||||||
/// handled by clap directly).
|
/// handled by clap directly).
|
||||||
#[arg(skip)]
|
#[arg(skip)]
|
||||||
|
|||||||
+57
-1
@@ -11,10 +11,38 @@ use crate::cli::AiArgs;
|
|||||||
use crate::commands::{require_agent, resolve_exec};
|
use crate::commands::{require_agent, resolve_exec};
|
||||||
use crate::events::{Event, EventKind};
|
use crate::events::{Event, EventKind};
|
||||||
use crate::shell_ai::{Decision, SafetyMode};
|
use crate::shell_ai::{Decision, SafetyMode};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
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(" ");
|
let prompt = args.prompt.join(" ");
|
||||||
// Context files: explicit --files wins, else the current directory
|
// Context files: explicit --files wins, else the current directory
|
||||||
// (issue #96: `aichat -f . "<prompt>"`).
|
// (issue #96: `aichat -f . "<prompt>"`).
|
||||||
@@ -39,6 +67,31 @@ pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
|||||||
exec_mode(app, &prompt, &files, args, yes, dry_run)
|
exec_mode(app, &prompt, &files, args, yes, dry_run)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the aichat binary is available (installed or on PATH).
|
||||||
|
/// Uses a direct PATH lookup — the probe cache would mask a shim added
|
||||||
|
/// mid-test and is meant for agent inventories, not runtime checks.
|
||||||
|
fn aichat_installed(app: &App) -> bool {
|
||||||
|
app.state.get("aichat").ok().flatten().is_some()
|
||||||
|
|| which::which("aichat").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the --role flag: the file must exist among the generated
|
||||||
|
/// am-* roles (v1.1.0 F3). Returns the aichat --agent arguments.
|
||||||
|
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
||||||
|
let Some(role) = role else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let dir = crate::roles::aichat_agents_dir();
|
||||||
|
let path = dir.join(format!("{role}.md"));
|
||||||
|
if !path.exists() {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
Ok(vec!["--agent".to_string(), role.to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
|
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
|
||||||
/// the provider/model environment applied when requested.
|
/// the provider/model environment applied when requested.
|
||||||
fn chat_mode(
|
fn chat_mode(
|
||||||
@@ -50,6 +103,7 @@ fn chat_mode(
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
let mut extra: Vec<String> = Vec::new();
|
let mut extra: Vec<String> = Vec::new();
|
||||||
|
extra.extend(role_args(app, args.role.as_deref())?);
|
||||||
for f in files {
|
for f in files {
|
||||||
extra.push("-f".to_string());
|
extra.push("-f".to_string());
|
||||||
extra.push(f.clone());
|
extra.push(f.clone());
|
||||||
@@ -102,12 +156,14 @@ fn exec_mode(
|
|||||||
"would generate & classify via aichat (exec) — commande non exécutée"
|
"would generate & classify via aichat (exec) — commande non exécutée"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let role = role_args(app, args.role.as_deref())?;
|
||||||
let cmd = crate::shell_ai::generate(
|
let cmd = crate::shell_ai::generate(
|
||||||
app,
|
app,
|
||||||
prompt,
|
prompt,
|
||||||
files,
|
files,
|
||||||
args.provider.as_deref(),
|
args.provider.as_deref(),
|
||||||
args.model.as_deref(),
|
args.model.as_deref(),
|
||||||
|
&role,
|
||||||
)?;
|
)?;
|
||||||
let settings = app
|
let settings = app
|
||||||
.config
|
.config
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
|||||||
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||||
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
|
Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
|
||||||
Command::Ai(args) => ai_cmd::run(app, args),
|
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::Start(a) => run_cmd::start(app, a),
|
||||||
Command::Stop {
|
Command::Stop {
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
+11
-1
@@ -110,17 +110,27 @@ pub static SECTIONS: &[TipSection] = &[
|
|||||||
example: "ask installe claude et lance-le",
|
example: "ask installe claude et lance-le",
|
||||||
},
|
},
|
||||||
TipEntry {
|
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",
|
about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut",
|
||||||
options: &[
|
options: &[
|
||||||
("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"),
|
("--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)"),
|
("-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)"),
|
("--provider", "provider du registre (clé du keyring + modèle)"),
|
||||||
("--model", "forcer un modèle"),
|
("--model", "forcer un modèle"),
|
||||||
("--yes", "exécute sans confirmation"),
|
("--yes", "exécute sans confirmation"),
|
||||||
],
|
],
|
||||||
example: "ai --exec \"compresse les fichiers JSON en un zip\"",
|
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 {
|
TipEntry {
|
||||||
usage: "models",
|
usage: "models",
|
||||||
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
|
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
|
||||||
|
|||||||
+20
@@ -857,6 +857,25 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
|
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 {
|
HelpSpec {
|
||||||
name: "ask",
|
name: "ask",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
@@ -887,6 +906,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
|
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: "--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: "--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: "-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)" },
|
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
|
||||||
],
|
],
|
||||||
|
|||||||
+11
@@ -169,6 +169,17 @@ pub const CATALOG: &[(&str, &str)] = &[
|
|||||||
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
|
("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 {}){}"),
|
("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\""),
|
("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)"),
|
||||||
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
|
("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>"),
|
("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"),
|
("installation refusée — source non fiable", "installation refused — untrusted source"),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod agent_config;
|
pub mod agent_config;
|
||||||
pub mod ask;
|
pub mod ask;
|
||||||
|
pub mod roles;
|
||||||
|
pub mod setup;
|
||||||
pub mod shell_ai;
|
pub mod shell_ai;
|
||||||
pub mod automation;
|
pub mod automation;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
|||||||
+23
-2
@@ -115,6 +115,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
|||||||
("suggest", "recommend an agent for a request"),
|
("suggest", "recommend an agent for a request"),
|
||||||
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
|
("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)"),
|
("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)"),
|
("audit", "who changed what, when (config checksums)"),
|
||||||
("service", "register an agent as a system service (autostart)"),
|
("service", "register an agent as a system service (autostart)"),
|
||||||
("schedule", "plan am commands (daily) and check the fleet health"),
|
("schedule", "plan am commands (daily) and check the fleet health"),
|
||||||
@@ -240,7 +241,7 @@ impl AmCompleter {
|
|||||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
"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",
|
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
|
||||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||||
],
|
],
|
||||||
@@ -800,7 +801,7 @@ pub fn banner_box(
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
rows.push(inner(
|
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(
|
rows.push(inner(
|
||||||
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
|
" 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:#}"));
|
app.log.verbose(&format!("history migration skipped: {e:#}"));
|
||||||
}
|
}
|
||||||
print!("{}", banner(app, &session, &sid));
|
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) {
|
let result = match run_with_editor(app, &mut session, &sid) {
|
||||||
Ok(code) => Ok(code),
|
Ok(code) => Ok(code),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1611,6 +1620,7 @@ fn handle_line(
|
|||||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||||
let mut provider: Option<String> = None;
|
let mut provider: Option<String> = None;
|
||||||
let mut model: Option<String> = None;
|
let mut model: Option<String> = None;
|
||||||
|
let mut role: Option<String> = None;
|
||||||
let mut prompt: Vec<String> = Vec::new();
|
let mut prompt: Vec<String> = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < rest.len() {
|
while i < rest.len() {
|
||||||
@@ -1636,6 +1646,12 @@ fn handle_line(
|
|||||||
model = Some(rest[i].clone());
|
model = Some(rest[i].clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--role" => {
|
||||||
|
i += 1;
|
||||||
|
if i < rest.len() {
|
||||||
|
role = Some(rest[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
other => prompt.push(other.to_string()),
|
other => prompt.push(other.to_string()),
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -1652,10 +1668,14 @@ fn handle_line(
|
|||||||
files,
|
files,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
role,
|
||||||
yes,
|
yes,
|
||||||
dry_run,
|
dry_run,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
"setup" => Command::Setup {
|
||||||
|
roles: flag("--roles"),
|
||||||
|
},
|
||||||
"alias" => match rest.first().map(|s| s.as_str()) {
|
"alias" => match rest.first().map(|s| s.as_str()) {
|
||||||
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
|
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
|
||||||
name: rest[1].clone(),
|
name: rest[1].clone(),
|
||||||
@@ -2073,6 +2093,7 @@ pub fn is_am_command(word: &str) -> bool {
|
|||||||
| "suggest"
|
| "suggest"
|
||||||
| "ask"
|
| "ask"
|
||||||
| "ai"
|
| "ai"
|
||||||
|
| "setup"
|
||||||
| "audit"
|
| "audit"
|
||||||
| "service"
|
| "service"
|
||||||
| "schedule"
|
| "schedule"
|
||||||
|
|||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
||||||
|
//!
|
||||||
|
//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or
|
||||||
|
//! `%APPDATA%\aichat\agents\` on Windows) 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.
|
||||||
|
//!
|
||||||
|
//! 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",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Directory where aichat stores its agent definitions. Candidates are
|
||||||
|
/// probed in order: an existing aichat config wins, otherwise the platform
|
||||||
|
/// default (XDG on unix, %APPDATA% on Windows).
|
||||||
|
pub fn aichat_agents_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").join("agents"));
|
||||||
|
}
|
||||||
|
if let Some(home) = crate::config::home_dir() {
|
||||||
|
candidates.push(home.join(".config").join("aichat").join("agents"));
|
||||||
|
}
|
||||||
|
if cfg!(windows) {
|
||||||
|
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
|
candidates.push(PathBuf::from(appdata).join("aichat").join("agents"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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").join("agents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::config::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".config")
|
||||||
|
.join("aichat")
|
||||||
|
.join("agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the aichat configuration file (created only if absent).
|
||||||
|
pub fn aichat_config_path() -> PathBuf {
|
||||||
|
aichat_agents_dir().parent().unwrap().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";
|
||||||
|
|
||||||
|
/// Build the markdown content of one role.
|
||||||
|
pub fn role_content(name: &str) -> Option<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,
|
||||||
|
};
|
||||||
|
let content = format!(
|
||||||
|
"{}{}{}",
|
||||||
|
frontmatter(name, desc, tools),
|
||||||
|
body,
|
||||||
|
LANG_FOOTER
|
||||||
|
);
|
||||||
|
Some(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write every am-* role into the aichat agents directory (creates the
|
||||||
|
/// directory when missing). Existing files with the same name are
|
||||||
|
/// overwritten — they are generated artifacts of am.
|
||||||
|
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
||||||
|
let dir = aichat_agents_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let mut written = Vec::new();
|
||||||
|
for name in ROLE_NAMES {
|
||||||
|
let content = role_content(name).expect("known role");
|
||||||
|
let path = 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 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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+389
@@ -0,0 +1,389 @@
|
|||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// Known providers offered by the wizard (from the embedded registry plus
|
||||||
|
/// the local Ollama runtime and a custom endpoint).
|
||||||
|
const CHOICES: &[&str] = &["anthropic", "openai", "deepseek", "google", "ollama", "custom"];
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_line() -> Result<String> {
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
Ok(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()?.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);
|
||||||
|
println!("Provider disponible :");
|
||||||
|
for (i, p) in CHOICES.iter().enumerate() {
|
||||||
|
let star = if Some(*p) == 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 raw = read_line()?;
|
||||||
|
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()?;
|
||||||
|
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 raw = read_line()?;
|
||||||
|
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()?;
|
||||||
|
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 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -280,9 +280,11 @@ pub fn generate(
|
|||||||
files: &[String],
|
files: &[String],
|
||||||
provider: Option<&str>,
|
provider: Option<&str>,
|
||||||
model: Option<&str>,
|
model: Option<&str>,
|
||||||
|
role_args: &[String],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let agent = require_agent(app, "aichat")?;
|
let agent = require_agent(app, "aichat")?;
|
||||||
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
|
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
|
||||||
|
extra_args.extend(role_args.iter().cloned());
|
||||||
for f in files {
|
for f in files {
|
||||||
extra_args.push("-f".to_string());
|
extra_args.push("-f".to_string());
|
||||||
extra_args.push(f.clone());
|
extra_args.push(f.clone());
|
||||||
|
|||||||
Reference in New Issue
Block a user