Compare commits

..
2 Commits
Author SHA1 Message Date
bruno 7fb65c257d feat: am setup — provider openrouter + modeles proposes ; am ai — role am-copilot par defaut ; fix migrate import (config active)
release / release (push) Successful in 13m7s
release / macos (push) Canceled after 0s
- setup: openrouter ajoute aux CHOICES (base URL, OPENROUTER_API_KEY,
  6 modeles declares dans le registre embarqué, proposes au choix)
- setup: pick_model liste les modeles disponibles (registre) + autre modele
- am ai sans --role: am-copilot par defaut (regeneration auto des roles si
  fichier manquant, degradation douce si echec; role explicite inconnu = erreur)
- fix migrate --import: import_bundle ecrivait la config dans user_config_dir()
  GLOBAL -> ecrasait la config reelle de la machine (et cargo test detruisait
  la config utilisateur a chaque suite via export_import_round_trip).
  L'import ecrit desormais dans app.paths.config_dir (config active).
v1.1.2
2026-08-21 08:36:26 -04:00
bruno 068940495a fix: am setup — ecrire settings.providers.<nom>.base_url + default_model (top-level 'providers:' rejete par le validateur)
release / release (push) Successful in 13m11s
release / macos (push) Canceled after 0s
Le wizard persistait le modele par defaut sous la cle 'providers.<nom>.default_model'
-> bloc 'providers:' au top-level de la config user -> 'unknown field providers'
(le registre vit sous settings.providers ; le schema exige base_url quand un
bloc provider est ecrit). Regression couverte par setup_persists_under_settings_providers.
v1.1.1
2026-08-21 07:43:26 -04:00
8 changed files with 130 additions and 23 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "1.1.0"
version = "1.1.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "1.1.0"
version = "1.1.2"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+20
View File
@@ -62,6 +62,26 @@
> 6 rôles copilot générés pour aichat (am-copilot, am-operator, am-dev,
> am-do, am-analyst, am-orchestrator), `am ai --role <nom>`, tip
> d'onboarding dans le banner REPL et les commandes IA (non-bloquant).
>
> 🔧 **Maintenance v1.1.1 (2026-08-20)** : fix `am setup` — le wizard écrivait
> `providers.<nom>` au TOP-LEVEL de la config user (clé rejetée par le
> validateur : « unknown field `providers` »). Le registre vivant sous
> `settings.providers`, le wizard écrit désormais
> `settings.providers.<nom>.base_url` + `.default_model` (le schéma exige
> base_url quand un bloc provider est écrit). Test de régression
> `setup_persists_under_settings_providers`.
>
> 🔧 **Maintenance v1.1.2 (2026-08-20)** :
> - `am setup` : **openrouter** ajouté au choix des providers (base URL,
> token via OPENROUTER_API_KEY, 6 modèles déclarés proposés) ;
> le choix du modèle liste les modèles disponibles du provider.
> - `am ai` sans `--role` utilise **am-copilot par défaut** (régénération
> automatique des rôles si le fichier manque, dégradation douce).
> - Bug `am migrate --import` : la config importée était écrite dans le
> répertoire config GLOBAL (user_config_dir) au lieu du config actif de
> l'app — l'import écrasait la config réelle de la machine (et la suite
> de tests détruisait la config utilisateur à chaque `cargo test`).
> L'import respecte désormais `app.paths.config_dir` (--config/isolated).
---
+10
View File
@@ -108,6 +108,16 @@ settings:
base_url: https://generativelanguage.googleapis.com/v1beta
default_model: gemini-2.5-pro
models: [gemini-2.5-pro, gemini-2.5-flash]
openrouter:
base_url: https://openrouter.ai/api/v1
default_model: deepseek/deepseek-chat-v3-0324
models:
- deepseek/deepseek-chat-v3-0324
- anthropic/claude-sonnet-4.5
- openai/gpt-4o
- google/gemini-2.5-pro
- meta-llama/llama-3.3-70b-instruct
- mistralai/mistral-large-2411
# --- Command aliases ----------------------------------------------------------
aliases:
+26 -11
View File
@@ -75,21 +75,36 @@ fn aichat_installed(app: &App) -> bool {
|| 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.
/// Build the aichat `--agent` arguments for the requested role. Without a
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
/// file is missing the roles are regenerated on the fly, and a generation
/// failure degrades gracefully (no role). An explicit unknown role is an
/// error.
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
let Some(role) = role else {
return Ok(Vec::new());
};
let role = role.unwrap_or("am-copilot");
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));
if path.exists() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
Ok(vec!["--agent".to_string(), role.to_string()])
if role == "am-copilot" {
if let Err(e) = crate::roles::generate_all(app) {
app.log.verbose(&format!("roles generation skipped: {e:#}"));
}
if path.exists() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
app.log.warn(crate::i18n::tr_in(
app.lang(),
"rôle 'am-copilot' introuvable — lancez am setup --roles",
));
return Ok(Vec::new());
}
bail!(crate::i18n::tr_in(
app.lang(),
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
)
.replace("{}", role));
}
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
+13 -5
View File
@@ -266,9 +266,14 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
let rel = Path::new(&bf.path);
let target = if bf.path == "config.yaml" {
// Config goes to the user config directory of machine B.
let dir = crate::config::user_config_dir()
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
// Config goes to the ACTIVE user config directory of this app
// (respects --config / isolated tests) — NOT the machine-global
// user config dir: importing must not clobber the real config.
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
dir.join(crate::config::CONFIG_FILE_NAME)
} else {
@@ -292,8 +297,11 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
// confirmed; otherwise it is written next to it as config.migrated.yaml.
let cfg_rel = "config.yaml";
if restored.iter().any(|r| r == cfg_rel) && !confirm {
let dir = crate::config::user_config_dir()
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
if imported.exists() {
let target = dir.join("config.migrated.yaml");
+1
View File
@@ -180,6 +180,7 @@ pub const CATALOG: &[(&str, &str)] = &[
("Config aichat créée (provider + modèle)", "aichat config created (provider + model)"),
("am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"", "am is configured — try: am ai \"...\" or am ai --exec \"...\""),
("rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)", "unknown role '{}' — generate the roles with: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
("rôle 'am-copilot' introuvable — lancez am setup --roles", "role 'am-copilot' not found — run am setup --roles"),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
("installation refusée — source non fiable", "installation refused — untrusted source"),
+58 -5
View File
@@ -16,7 +16,7 @@ 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"];
const CHOICES: &[&str] = &["anthropic", "openai", "deepseek", "google", "openrouter", "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
@@ -229,10 +229,26 @@ pub fn run(app: &App) -> Result<i32> {
}
}
// 3) Default model.
// 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 != "custom" {
config_cmd::persist_setting(app, &format!("providers.{provider}.default_model"), &model)?;
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.
@@ -295,11 +311,16 @@ mod tests {
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}",
@@ -315,7 +336,7 @@ mod tests {
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
App::from_cli(cli).unwrap()
(App::from_cli(cli).unwrap(), cfg)
}
#[test]
@@ -333,4 +354,36 @@ mod tests {
);
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")
);
}
}