From b7f29baea5960d1415e29861a15283c29d61ebf5 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 1 Sep 2026 11:40:05 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20assistant=20setup=20=E2=80=94=20liste=20?= =?UTF-8?q?des=20providers=20construite=20depuis=20le=20registre=20(QwenCl?= =?UTF-8?q?oud=20absents=20de=20la=20liste=20codee=20en=20dur,=20v1.1.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 8 +++++ man/am.1 | 4 +-- src/setup.rs | 83 ++++++++++++++++++++++++++++++++++++++++++---------- 5 files changed, 79 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 637c9df..9916038 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "1.1.8" +version = "1.1.9" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 3e80f52..9173bfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "1.1.8" +version = "1.1.9" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index 02a9ca2..3585289 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,14 @@ > OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer > sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via > `am providers set-token`). +> +> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses +> providers dans une constante codée en dur — les QwenCloud ajoutés au +> registre n'y apparaissaient pas. La liste est maintenant construite depuis +> le registre fusionné (embarqué + config utilisateur), ollama et custom en +> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne +> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion +> `--yes`). --- diff --git a/man/am.1 b/man/am.1 index fe26cf5..1b790aa 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.1.8" +.TH am 1 "am 1.1.9" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -250,4 +250,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v1.1.8 +v1.1.9 diff --git a/src/setup.rs b/src/setup.rs index 4ea32ee..3750cfe 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -14,9 +14,22 @@ 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", "openrouter", "ollama", "custom"]; +/// Providers offered by the wizard: every provider defined in the merged +/// registry (embedded + user config, sorted), plus the local Ollama runtime +/// and a custom endpoint. Built dynamically so new registry providers +/// (e.g. the QwenCloud token-plan / pay-as-you-go pair) show up without a +/// code change — CHOICES used to be a hardcoded list that silently drifted. +fn choices(app: &App) -> Vec { + let mut v: Vec = crate::providers::defined(&app.config) + .map(|(k, _)| k.clone()) + .collect(); + v.sort(); + if !v.iter().any(|p| p == "ollama") { + v.push("ollama".to_string()); + } + v.push("custom".to_string()); + v +} /// True when the user has not configured a usable default provider yet: /// no `settings.default_provider`, or the default provider has no API @@ -36,10 +49,14 @@ pub fn needs_setup(app: &App) -> bool { .is_none() } -fn read_line() -> Result { +/// Read one trimmed line from stdin. `None` on EOF — callers must not spin +/// on empty input when nothing is connected (pipes, --yes in CI). +fn read_line() -> Result> { let mut line = String::new(); - std::io::stdin().read_line(&mut line)?; - Ok(line.trim().to_string()) + if std::io::stdin().read_line(&mut line)? == 0 { + return Ok(None); + } + Ok(Some(line.trim().to_string())) } /// Ask a y/N question on stderr, defaulting to yes when `yes` is set. @@ -50,7 +67,7 @@ fn ask(app: &App, question: &str, default_yes: bool) -> Result { let suffix = if default_yes { "[Y/n]" } else { "[y/N]" }; eprint!("{question} {suffix} "); std::io::stderr().flush()?; - let answer = read_line()?.to_lowercase(); + let answer = read_line()?.unwrap_or_default().to_lowercase(); Ok(match answer.as_str() { "" => default_yes, "y" | "yes" | "o" | "oui" | "true" => true, @@ -61,9 +78,10 @@ fn ask(app: &App, question: &str, default_yes: bool) -> Result { /// 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); + let choices = choices(app); println!("Provider disponible :"); - for (i, p) in CHOICES.iter().enumerate() { - let star = if Some(*p) == current { " (actuel)" } else { "" }; + for (i, p) in choices.iter().enumerate() { + let star = if Some(p.as_str()) == current { " (actuel)" } else { "" }; println!(" {}) {}{star}", i + 1, p); } if app.cli.yes { @@ -71,16 +89,18 @@ fn pick_provider(app: &App) -> Result<(String, Option)> { return Ok((name, None)); } loop { - print!("Choisissez (1-{}) : ", CHOICES.len()); + print!("Choisissez (1-{}) : ", choices.len()); std::io::stdout().flush()?; - let raw = read_line()?; + let Some(raw) = read_line()? else { + bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'"); + }; if let Ok(n) = raw.parse::() { - if (1..=CHOICES.len()).contains(&n) { - let name = CHOICES[n - 1]; + 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()?; + let url = read_line()?.unwrap_or_default(); if url.is_empty() { continue; } @@ -126,7 +146,9 @@ fn pick_model(app: &App, name: &str) -> Result { loop { print!("Modèle par défaut : "); std::io::stdout().flush()?; - let raw = read_line()?; + let Some(raw) = read_line()? else { + bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'"); + }; if let Ok(n) = raw.parse::() { if n >= 1 && n <= models.len() { return Ok(models[n - 1].clone()); @@ -134,7 +156,7 @@ fn pick_model(app: &App, name: &str) -> Result { if n == models.len() + 1 { print!("Nom du modèle : "); std::io::stdout().flush()?; - let custom = read_line()?; + let custom = read_line()?.unwrap_or_default(); if !custom.is_empty() { return Ok(custom); } @@ -347,6 +369,35 @@ mod tests { assert!(needs_setup(&app)); } + #[test] + fn wizard_offers_every_registry_provider() { + // Regression (v1.1.8): the wizard used a hardcoded CHOICES list and + // the QwenCloud providers added to the registry never appeared. + // The list must now track the merged registry + ollama + custom. + let app = app_with(""); + let c = choices(&app); + for p in [ + "anthropic", + "openai", + "deepseek", + "google", + "openrouter", + "qwen-cloud", + "qwen-cloud-token-plan", + "ollama", + "custom", + ] { + assert!(c.iter().any(|x| x == p), "wizard must offer {p}: {c:?}"); + } + // User-defined providers show up too, ollama is never duplicated. + let app2 = app_with( + " default_provider: anthropic\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n my-llm:\n base_url: https://llm.internal/v1\n", + ); + let c2 = choices(&app2); + assert_eq!(c2.iter().filter(|x| *x == "ollama").count(), 1, "{c2:?}"); + assert!(c2.iter().any(|x| x == "my-llm"), "{c2:?}"); + } + #[test] fn needs_setup_false_for_local_ollama() { let app = app_with(