From d71c3dd34dd2818fcf0d7de03e65e936d6411f81 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 20 Aug 2026 23:09:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20epique=20v1.1.0=20=E2=80=94=20onboardin?= =?UTF-8?q?g,=20copilot=20&=20roles=20aichat=20(am=20setup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — Wizard d'onboarding (src/setup.rs) : - am setup : provider (anthropic, openai, deepseek, google, ollama, custom), token masque (rpassword) -> keyring OS (jamais en clair), modele par defaut (liste declaree + champ libre), ping API non-bloquant - Declencheurs non-bloquants : am ai / am ask et banner REPL proposent am setup quand aucun provider n'est configure ; re-executable (mode revoir) - settings.default_provider + providers.* ecrits via persist_setting F2 — Integration aichat : - Etape wizard 'Installer le moteur IA (aichat) ? [Y/n]' -> am install aichat - am ai sans aichat -> 'aichat est manquant. Installer ? [Y/n]' - install.ps1/install.sh enchainent sur am setup si stdin interactif F3 — Roles copilot aichat (src/roles.rs) : - 6 agents generes dans ~/.config/aichat/agents (ou %APPDATA%\aichat\agents) : am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator - Prompts bilingues (FR defaut) privilegiant les contrats --json ; le prompt am-copilot embarque la liste des commandes am - am ai --role (valide le fichier genere, passe --agent a aichat), completion REPL ; config aichat creee seulement si absente - Câblage complet : cli, dispatch, help, REPL, tip, man (am-setup.1), i18n Tests : 450 verts (roles + setup unitaires, ai_test ajustes locale-agnostic) v1.1.0 ; ROADMAP axe 14 + maintenance note ; README Premier lancement --- Cargo.lock | 24 ++- Cargo.toml | 3 +- README.md | 29 +++- ROADMAP.md | 26 +++- install.ps1 | 7 + install.sh | 7 + man/am-ai.1 | 5 +- man/am-setup.1 | 16 ++ man/am.1 | 7 +- src/cli.rs | 10 ++ src/commands/ai_cmd.rs | 58 ++++++- src/commands/mod.rs | 7 + src/commands/tip_cmd.rs | 12 +- src/help.rs | 20 +++ src/i18n.rs | 11 ++ src/lib.rs | 2 + src/repl.rs | 25 ++- src/roles.rs | 275 ++++++++++++++++++++++++++++++++ src/setup.rs | 336 ++++++++++++++++++++++++++++++++++++++++ src/shell_ai.rs | 2 + 20 files changed, 870 insertions(+), 12 deletions(-) create mode 100644 man/am-setup.1 create mode 100644 src/roles.rs create mode 100644 src/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 770bd4a..361a1ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "1.0.6" +version = "1.1.0" 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" diff --git a/Cargo.toml b/Cargo.toml index f1bef57..e040278 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "1.0.6" +version = "1.1.0" 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" diff --git a/README.md b/README.md index a3d7313..7d36873 100644 --- a/README.md +++ b/README.md @@ -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 | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » | | am suggest | recommande un agent pour une demande en langage naturel (tags + usage réel) | | am ask "" | 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 "" [--exec] [--files ] | 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 "" [--exec] [--files ] [--role ] | 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 | 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) | diff --git a/ROADMAP.md b/ROADMAP.md index 2996c7e..2aa073b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # 🗺️ 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.0** · Statut : phases 0 à 3 livrées +> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.1.0 > Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime) > > ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées, @@ -55,6 +55,13 @@ > 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 `, tip +> d'onboarding dans le banner REPL et les commandes IA (non-bloquant). --- @@ -456,6 +463,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 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 `** | 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 | --- diff --git a/install.ps1 b/install.ps1 index 8323f46..d11c9b7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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" diff --git a/install.sh b/install.sh index f88b1db..2c59412 100644 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/man/am-ai.1 b/man/am-ai.1 index f1d77be..b6d2af8 100644 --- a/man/am-ai.1 +++ b/man/am-ai.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\fR Force a model (aichat \-\-model) .TP +\fB\-\-role\fR \fI\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 diff --git a/man/am-setup.1 b/man/am-setup.1 new file mode 100644 index 0000000..f767027 --- /dev/null +++ b/man/am-setup.1 @@ -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 diff --git a/man/am.1 b/man/am.1 index 5f25697..4628a36 100644 --- a/man/am.1 +++ b/man/am.1 @@ -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.0" .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.0 diff --git a/src/cli.rs b/src/cli.rs index 1569255..60b082c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + /// 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, /// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is /// handled by clap directly). #[arg(skip)] diff --git a/src/commands/ai_cmd.rs b/src/commands/ai_cmd.rs index 9ab18c3..c64809e 100644 --- a/src/commands/ai_cmd.rs +++ b/src/commands/ai_cmd.rs @@ -11,10 +11,38 @@ 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 { + // 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 . ""`). @@ -39,6 +67,31 @@ pub fn run(app: &App, args: &AiArgs) -> Result { 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> { + 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 "" in the foreground, with /// the provider/model environment applied when requested. fn chat_mode( @@ -50,6 +103,7 @@ fn chat_mode( dry_run: bool, ) -> Result { let mut extra: Vec = 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 +156,14 @@ fn exec_mode( "would generate & classify via aichat (exec) — commande non exécutée" )); } + let role = role_args(app, args.role.as_deref())?; let cmd = crate::shell_ai::generate( app, prompt, files, args.provider.as_deref(), args.model.as_deref(), + &role, )?; let settings = app .config diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ac2b085..a5c8252 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -108,6 +108,13 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { 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, diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index acb4e70..b135115 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -110,17 +110,27 @@ pub static SECTIONS: &[TipSection] = &[ example: "ask installe claude et lance-le", }, TipEntry { - usage: "ai [--exec] [--files ]", + usage: "ai [--exec] [--files ] [--role ]", 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)", diff --git a/src/help.rs b/src/help.rs index 28b331b..1231c0f 100644 --- a/src/help.rs +++ b/src/help.rs @@ -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", @@ -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: "", 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)" }, ], diff --git a/src/i18n.rs b/src/i18n.rs index 58f5b8e..e646b5a 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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 pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"), ("usage: ai — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai — 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 — ex: ask installe claude et lance-le", "usage: ask — e.g. ask installe claude et lance-le"), ("aucune source enregistrée — settings.registry.sources ou: am registry install ", "no sources registered — settings.registry.sources or: am registry install "), ("installation refusée — source non fiable", "installation refused — untrusted source"), diff --git a/src/lib.rs b/src/lib.rs index a8b9fea..7df26d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/repl.rs b/src/repl.rs index d805d51..9d2235f 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -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 { 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 = Vec::new(); let mut provider: Option = None; let mut model: Option = None; + let mut role: Option = None; let mut prompt: Vec = 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" diff --git a/src/roles.rs b/src/roles.rs new file mode 100644 index 0000000..2dde67a --- /dev/null +++ b/src/roles.rs @@ -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 = 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 { + 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 , am search .\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 .", + 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 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 --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 --json\n\ + - Automatiser : am schedule add , am service install , am playbook \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> { + 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 { + 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:?}"); + } +} diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..056012c --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,336 @@ +//! 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 { + 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 { + 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)> { + 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::() { + 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 { + 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 { + 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::() { + 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 { + 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 = 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. + let model = pick_model(app, &provider)?; + if provider != "custom" { + config_cmd::persist_setting(app, &format!("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 { + 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::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn app_with(settings_yaml: &str) -> App { + 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() + } + + #[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)); + } +} diff --git a/src/shell_ai.rs b/src/shell_ai.rs index 2bdf998..eef4ffd 100644 --- a/src/shell_ai.rs +++ b/src/shell_ai.rs @@ -280,9 +280,11 @@ pub fn generate( files: &[String], provider: Option<&str>, model: Option<&str>, + role_args: &[String], ) -> Result { let agent = require_agent(app, "aichat")?; let mut extra_args: Vec = vec!["--exec".to_string(), "--dry-run".to_string()]; + extra_args.extend(role_args.iter().cloned()); for f in files { extra_args.push("-f".to_string()); extra_args.push(f.clone());