diff --git a/ROADMAP.md b/ROADMAP.md index 66adc0e..19a8d5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -499,7 +499,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés |---|---|---|---| | [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — | | [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | ✅ Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 | -| [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 | +| [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | ✅ AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 | | [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 | | [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 | diff --git a/config.yaml b/config.yaml index de8a9ff..dc00a2c 100644 --- a/config.yaml +++ b/config.yaml @@ -45,18 +45,28 @@ settings: # sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in) # Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par # défaut. Le provider par défaut est utilisé à l'install/au run quand aucun - # n'est donné. Les tokens ne vont JAMAIS ici — ils vivent dans le keyring OS - # sous providers//api_key (issue #89, am providers set-token). - # default_provider: openai - # providers: - # openai: - # base_url: https://api.openai.com/v1 - # default_model: gpt-5.2 - # models: [gpt-5.2, gpt-5.1-mini, o4-mini] - # deepseek: - # base_url: https://api.deepseek.com - # default_model: deepseek-chat - # models: [deepseek-chat, deepseek-reasoner] + # n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans + # le keyring OS sous providers//api_key (issue #89, am providers set-token). + # Pour retirer un provider du catalogue par défaut : am providers remove + # (écrit ": null" dans votre config — le null supprime l'entrée au merge). + default_provider: anthropic + providers: + anthropic: + base_url: https://api.anthropic.com/v1 + default_model: claude-sonnet-4-5 + models: [claude-sonnet-4-5, claude-opus-4-1, claude-haiku-4-5] + openai: + base_url: https://api.openai.com/v1 + default_model: gpt-5.2 + models: [gpt-5.2, gpt-5.1-mini, o4-mini] + deepseek: + base_url: https://api.deepseek.com + default_model: deepseek-chat + models: [deepseek-chat, deepseek-reasoner] + google: + base_url: https://generativelanguage.googleapis.com/v1beta + default_model: gemini-2.5-pro + models: [gemini-2.5-pro, gemini-2.5-flash] # --- Command aliases ---------------------------------------------------------- aliases: @@ -92,6 +102,7 @@ agents: dependencies: - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } run: claude + provider: anthropic tags: [anthropic, assistant, source-available, mainstream] - name: deepseek-harness @@ -105,6 +116,7 @@ agents: dependencies: - { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" } run: dsh + provider: deepseek tags: [deepseek, harness, multi-agent, plugins, web-ui] - name: hermes-agent diff --git a/man/am-install.1 b/man/am-install.1 index 8e6e72c..51aa5ef 100644 --- a/man/am-install.1 +++ b/man/am-install.1 @@ -4,7 +4,7 @@ .SH NAME install \- Install an agent and its dependencies .SH SYNOPSIS -\fBinstall\fR [\fB\-\-method\fR] [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> +\fBinstall\fR [\fB\-\-method\fR] [\fB\-\-force\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-\-no\-config\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> .SH DESCRIPTION Install an agent and its dependencies .SH OPTIONS @@ -15,6 +15,15 @@ Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, b \fB\-\-force\fR Reinstall even if already installed .TP +\fB\-\-provider\fR \fI\fR +Provider to configure (default: the agent\*(Aqs provider, else settings.default_provider) (issue #90) +.TP +\fB\-\-model\fR \fI\fR +Model to configure (default: the provider\*(Aqs default model) (issue #90) +.TP +\fB\-\-no\-config\fR +Skip the post\-install provider configuration (issue #91) +.TP \fB\-h\fR, \fB\-\-help\fR Print help .TP diff --git a/src/cli.rs b/src/cli.rs index fc6dbaa..2db31fe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -359,6 +359,15 @@ pub enum Command { /// Reinstall even if already installed #[arg(long)] force: bool, + /// Provider to configure (default: the agent's provider, else settings.default_provider) (issue #90) + #[arg(long, value_name = "PROVIDER")] + provider: Option, + /// Model to configure (default: the provider's default model) (issue #90) + #[arg(long, value_name = "MODEL")] + model: Option, + /// Skip the post-install provider configuration (issue #91) + #[arg(long)] + no_config: bool, }, /// Uninstall an agent (managed or external) and clean its files Uninstall { diff --git a/src/commands/install_cmd.rs b/src/commands/install_cmd.rs index b9021c0..a616976 100644 --- a/src/commands/install_cmd.rs +++ b/src/commands/install_cmd.rs @@ -8,7 +8,15 @@ use crate::runner::SystemRunner; use anyhow::{anyhow, bail, Result}; use std::collections::BTreeMap; -pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -> Result { +pub fn run( + app: &App, + agent_name: &str, + method_sel: Option<&str>, + force: bool, + provider: Option<&str>, + model: Option<&str>, + no_config: bool, +) -> Result { let agent = require_agent(app, agent_name)?; let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log); @@ -38,6 +46,20 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) - let methods = spec.all_methods(); let method = select_method(&methods, method_sel)?; + // Provider/model resolution (issue #90): flag > agent.provider > + // settings.default_provider ; flag --model > agent.model > + // provider.default_model. Displayed in the summary (and by --dry-run). + let resolved = resolve_provider_config(app, agent, provider, model)?; + if no_config { + app.log + .info(crate::i18n::tr("--no-config : aucune configuration post-install")); + } else if !spec.configurable { + app.log.info(&crate::tr_fmt!( + "{} ne prend aucune configuration (install.configurable: false)", + agent.name + )); + } + // Already installed? let existing = app.state.get(&agent.name).ok().flatten(); if existing.is_some() && !force && !app.dry_run() { @@ -138,10 +160,17 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) - let entry = installers::make_entry(agent, method, &outcome, app); let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string()); app.state.set(&entry)?; + let mut args = vec![format!("method={}", method.kind)]; + if let Some((p, m)) = &resolved { + args.push(format!("provider={p}")); + if let Some(m) = m { + args.push(format!("model={m}")); + } + } app.emit( &Event::now(EventKind::Install) .with_agent(agent.name.clone()) - .with_args(vec![format!("method={}", method.kind)]), + .with_args(args), ); crate::hooks::run_hooks( app, @@ -161,3 +190,84 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) - } Ok(0) } + +/// Resolve the provider + model of this install (issue #90) and print what +/// will be configured. Returns (provider, model) when resolved. +/// +/// Validation rules: +/// - an explicit `--provider` that is not registered → error listing the +/// registry (the user asked for it by name); +/// - an agent-declared provider that is not registered → warning only +/// (catalog metadata may reference a provider the user has not added yet); +/// - a model absent from the provider's model list → warning, not blocking. +fn resolve_provider_config( + app: &App, + agent: &AgentDef, + flag_provider: Option<&str>, + flag_model: Option<&str>, +) -> Result)>> { + let flag_p = flag_provider.filter(|p| !p.is_empty()); + let agent_p = agent.provider.as_deref().filter(|p| !p.is_empty()); + + // Explicit provider requested but not registered: hard error. + if let Some(p) = flag_p { + if crate::providers::get(&app.config, p).is_none() { + let known = crate::providers::names(&app.config); + let hint = if known.is_empty() { + "am providers add --base-url ".to_string() + } else { + known.join(", ") + }; + bail!(crate::tr_fmt!( + "provider '{}' inconnu — providers enregistrés: {}", + p, + hint + )); + } + } + // Agent-declared provider not registered: warn (unless the user already + // overrode it with --provider, which was validated above). + if let Some(p) = agent_p { + if flag_p.is_none() && crate::providers::get(&app.config, p).is_none() { + app.log.warn(&crate::tr_fmt!( + "provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url ", + p, + agent.name, + p + )); + return Ok(None); + } + } + + let Some((pname, def, model)) = crate::providers::resolve_for( + &app.config, + agent.provider.as_deref(), + agent.model.as_deref(), + flag_provider, + flag_model, + ) else { + app.log.info(crate::i18n::tr( + "aucun provider configuré — voir: am providers add --base-url ", + )); + return Ok(None); + }; + + // Model not in the provider's list: warn, keep going. + if let Some(m) = model { + if !def.models.is_empty() && !def.models.iter().any(|x| x == m) { + app.log.warn(&crate::tr_fmt!( + "modèle '{}' absent de la liste du provider '{}' — modèles: {}", + m, + pname, + def.models.join(", ") + )); + } + } + let model_label = model.unwrap_or("-"); + app.log.info(&crate::tr_fmt!( + "configuré avec le provider {} (modèle {})", + pname, + model_label + )); + Ok(Some((pname.to_string(), model.map(String::from)))) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 671c9e6..357f1cc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -169,7 +169,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { agent, method, force, - } => install_cmd::run(app, agent, method.as_deref(), *force), + provider, + model, + no_config, + } => install_cmd::run( + app, + &agent, + method.as_deref(), + *force, + provider.as_deref(), + model.as_deref(), + *no_config, + ), Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), Command::Update { agent, all, rollback } => { update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref()) @@ -311,7 +322,10 @@ pub fn resolve_exec( crate::secrets::resolve_env_secrets( &crate::secrets::store(), &agent.name, - crate::providers::default_name(&app.config), + agent + .provider + .as_deref() + .or_else(|| crate::providers::default_name(&app.config)), &mut env, ); for w in warnings { diff --git a/src/commands/providers_cmd.rs b/src/commands/providers_cmd.rs index 6340421..9a6f6df 100644 --- a/src/commands/providers_cmd.rs +++ b/src/commands/providers_cmd.rs @@ -38,15 +38,19 @@ pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result { fn list(app: &App) -> Result { let providers = providers::all(&app.config); let default_name = providers::default_name(&app.config); + let defined: Vec<(&String, &ProviderDef)> = providers + .iter() + .filter_map(|(k, v)| v.as_ref().map(|def| (k, def))) + .collect(); if app.json() { - let rows: Vec = providers + let rows: Vec = defined .iter() .map(|(name, def)| provider_json(name, def, default_name == Some(name.as_str()))) .collect(); print_json(&rows); return Ok(0); } - if providers.is_empty() { + if defined.is_empty() { app.log.info(&crate::i18n::tr( "aucun provider enregistré — voir: am providers add --base-url ", )); @@ -58,7 +62,7 @@ fn list(app: &App) -> Result { "DÉFAUT", "MODÈLES", ]); - for (name, def) in providers { + for (name, def) in &defined { let star = if default_name == Some(name.as_str()) { "★" } else { @@ -226,7 +230,14 @@ fn remove(app: &App, name: &str) -> Result { } let path = crate::commands::config_cmd::active_config_path(app)?; let mut root = read_yaml(&path)?; - remove_path(&mut root, &["settings", "providers", name])?; + // Write an explicit `null` instead of removing the key: the effective + // config is a merge (default catalog + user overlay) and a null marker + // is what actually deletes a provider shipped in the defaults (issue #90). + set_path( + &mut root, + &["settings", "providers", name], + serde_yaml::Value::Null, + )?; write_yaml(&path, &root)?; crate::commands::config_cmd::reload_and_validate(&path)?; app.emit( @@ -297,7 +308,9 @@ fn write_yaml(path: &Path, root: &serde_yaml::Value) -> Result<()> { .map_err(|e| anyhow!("cannot write {}: {e}", path.display())) } -/// Set a value at a dotted path, creating intermediate mappings. +/// Set a value at a dotted path, creating intermediate mappings. A null +/// value already present on the path (a provider deletion marker from +/// `providers remove`, issue #90) is replaced by a fresh mapping. fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Value) -> Result<()> { let mut node = root .as_mapping_mut() @@ -306,6 +319,9 @@ fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Val let entry = node .entry(serde_yaml::Value::String(part.to_string())) .or_insert_with(|| serde_yaml::Value::Mapping(Default::default())); + if entry.is_null() { + *entry = serde_yaml::Value::Mapping(Default::default()); + } node = entry .as_mapping_mut() .ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?; @@ -466,9 +482,11 @@ mod tests { p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; - add(&app, "openai", "https://api.openai.com/v1", None, None).unwrap(); + // "localai" is not shipped in the embedded default catalog, so its + // presence proves a real write happened. + add(&app, "localai", "http://127.0.0.1:8080/v1", None, None).unwrap(); let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let reloaded = App::from_cli(cli2).unwrap(); - assert!(providers::get(&reloaded.config, "openai").is_none()); + assert!(providers::get(&reloaded.config, "localai").is_none()); } } diff --git a/src/commands/suggest_cmd.rs b/src/commands/suggest_cmd.rs index 14ae216..bc51607 100644 --- a/src/commands/suggest_cmd.rs +++ b/src/commands/suggest_cmd.rs @@ -196,6 +196,8 @@ mod tests { pin_version: None, model_env: None, model_arg: None, + provider: None, + model: None, tags: tags.iter().map(|s| s.to_string()).collect(), installable: false, note: None, diff --git a/src/config.rs b/src/config.rs index 2a21bf7..52b9d9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -148,7 +148,7 @@ pub struct Settings { /// they live in the OS keyring under `providers//api_key` /// (issue #89). #[serde(default)] - pub providers: Option>, + pub providers: Option>>, } /// One entry of the LLM provider registry (issue #88). @@ -275,7 +275,10 @@ impl Settings { /// Look up one provider of the registry (issue #88). pub fn provider(&self, name: &str) -> Option<&ProviderDef> { - self.providers.as_ref().and_then(|m| m.get(name)) + self.providers + .as_ref() + .and_then(|m| m.get(name)) + .and_then(|p| p.as_ref()) } /// Name of the default provider (issue #88). @@ -331,6 +334,14 @@ pub struct AgentDef { /// CLI flag carrying the model name at launch (issue #71, e.g. "--model"). #[serde(default)] pub model_arg: Option, + /// Preferred provider of this agent (issue #90, e.g. "anthropic" for + /// claude-code). Falls back to settings.default_provider when unset. + #[serde(default)] + pub provider: Option, + /// Preferred model for this agent (issue #90). Falls back to the + /// provider's default_model when unset. + #[serde(default)] + pub model: Option, #[serde(default)] pub tags: Vec, /// When false, the agent is listed but cannot be installed locally. @@ -588,6 +599,10 @@ pub struct InstallSpec { /// Commands executed after a successful install (PATH includes the bin dir). #[serde(default)] pub post_install: Vec, + /// The agent accepts a post-install provider configuration (issue #91). + /// False for agents without any API (issue #90). + #[serde(default = "default_true")] + pub configurable: bool, } impl InstallSpec { @@ -815,12 +830,21 @@ pub fn merge(base: &mut Config, overlay: Config) { s.default_provider = o.default_provider; } // Providers merge key by key (issue #88): an overlay adds or replaces - // one provider without wiping the others. + // one provider without wiping the others. An explicit `null` in the + // overlay DELETES the provider (the standard YAML overlay pattern) so a + // provider shipped in the default catalog can be removed by the user. if let Some(overlay_providers) = o.providers { match s.providers.as_mut() { Some(map) => { for (k, v) in overlay_providers { - map.insert(k, v); + match v { + Some(def) => { + map.insert(k, Some(def)); + } + None => { + map.remove(&k); + } + } } } None => s.providers = Some(overlay_providers), @@ -900,6 +924,10 @@ pub fn validate(config: &Config) -> Vec { } if let Some(providers) = &config.settings.providers { for (name, p) in providers { + // A null entry is a deletion marker (issue #90): skip it. + let Some(p) = p else { + continue; + }; if p.base_url.trim().is_empty() { problems.push(format!("provider '{name}': 'base_url' is required")); } diff --git a/src/help.rs b/src/help.rs index b5a63fa..6ad08c3 100644 --- a/src/help.rs +++ b/src/help.rs @@ -797,11 +797,14 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "install", category: "Commands", usage: "install {flags} ", - about: "Install an agent and its dependencies.", - search_terms: &["add", "setup"], + about: "Install an agent and its dependencies, and resolve its LLM provider + model (issue #90): --provider > agent.provider > settings.default_provider; --model > agent.model > provider.default_model.", + search_terms: &["add", "setup", "provider", "model"], flags: &[ HelpFlag { short: "", long: "--method", value: "METHOD", desc: "Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)" }, HelpFlag { short: "", long: "--force", value: "", desc: "Reinstall even if already installed" }, + HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider to configure (default: the agent's provider, else settings.default_provider)" }, + HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to configure (default: the provider's default model)" }, + HelpFlag { short: "", long: "--no-config", value: "", desc: "Skip the post-install provider configuration (issue #91)" }, ], subcommands: &[], parameters: &[ @@ -812,6 +815,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Install an agent with its dependencies.", code: "install claude-code" }, HelpExample { desc: "Pick a specific install method.", code: "install smelt --method uv" }, HelpExample { desc: "Reinstall over an existing install.", code: "install claude-code --force" }, + HelpExample { desc: "Force a provider and a model.", code: "install agentty --provider deepseek --model deepseek-reasoner" }, + HelpExample { desc: "Preview the resolved provider/model without installing.", code: "install agentty --dry-run" }, + HelpExample { desc: "Install without any post-install configuration.", code: "install agentty --no-config" }, ], }, HelpSpec { diff --git a/src/i18n.rs b/src/i18n.rs index 8242458..c8a0f5a 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -178,6 +178,13 @@ pub const CATALOG: &[(&str, &str)] = &[ ("secret '{}' stocké pour {}", "secret '{}' stored for {}"), ("secret '{}' supprimé pour {}", "secret '{}' removed for {}"), ("secret '{}' inexistant pour {}", "secret '{}' does not exist for {}"), + ("--no-config : aucune configuration post-install", "--no-config: no post-install configuration"), + ("{} ne prend aucune configuration (install.configurable: false)", "{} takes no configuration (install.configurable: false)"), + ("provider '{}' inconnu — providers enregistrés: {}", "unknown provider '{}' — registered providers: {}"), + ("provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url ", "provider '{}' (declared by {}) is not registered — see: am providers add {} --base-url "), + ("aucun provider configuré — voir: am providers add --base-url ", "no provider configured — see: am providers add --base-url "), + ("modèle '{}' absent de la liste du provider '{}' — modèles: {}", "model '{}' is not in provider '{}' model list — models: {}"), + ("configuré avec le provider {} (modèle {})", "configured with provider {} (model {})"), ("usage: secret set --agent --value | secret set --provider --value ", "usage: secret set --agent --value | secret set --provider --value "), ("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"), ("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"), diff --git a/src/providers.rs b/src/providers.rs index d6e6431..6f93fef 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -10,13 +10,21 @@ use crate::config::{Config, ProviderDef}; use std::collections::BTreeMap; /// Empty provider map shared by the helpers below (never mutated). -static EMPTY: BTreeMap = BTreeMap::new(); +static EMPTY: BTreeMap> = BTreeMap::new(); /// Every provider of the effective configuration (empty when unset). -pub fn all(config: &Config) -> &BTreeMap { +/// `None` values are the YAML null-deletion markers and are skipped. +pub fn all(config: &Config) -> &BTreeMap> { config.settings.providers.as_ref().unwrap_or(&EMPTY) } +/// The providers actually defined (null markers filtered out). +pub fn defined(config: &Config) -> impl Iterator { + all(config) + .iter() + .filter_map(|(k, v)| v.as_ref().map(|def| (k, def))) +} + /// Look up one provider by name. pub fn get<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderDef> { config.settings.provider(name) @@ -41,6 +49,38 @@ pub fn model_for<'a>(provider: &'a ProviderDef, explicit: Option<&'a str>) -> Op .or(provider.default_model.as_deref()) } +/// Sorted provider names (for error messages listing the registry). +pub fn names(config: &Config) -> Vec { + let mut v: Vec = defined(config).map(|(k, _)| k.clone()).collect(); + v.sort(); + v +} + +/// Resolve the provider + model of an agent at install time (issue #90): +/// `flag --provider` > `agent.provider` > `settings.default_provider` ; +/// `flag --model` > `agent.model` > `provider.default_model`. +/// Returns None when no provider is configured at all. +pub fn resolve_for<'a>( + config: &'a Config, + agent_provider: Option<&'a str>, + agent_model: Option<&'a str>, + flag_provider: Option<&'a str>, + flag_model: Option<&'a str>, +) -> Option<(&'a str, &'a ProviderDef, Option<&'a str>)> { + let provider_name = flag_provider + .filter(|p| !p.is_empty()) + .or(agent_provider.filter(|p| !p.is_empty())) + .or_else(|| default_name(config))?; + let def = get(config, provider_name)?; + let model = model_for( + def, + flag_model + .filter(|m| !m.is_empty()) + .or(agent_model.filter(|m| !m.is_empty())), + ); + Some((provider_name, def, model)) +} + /// Provider names accept the same slug rule as agent names: [a-z0-9_-]. pub fn is_valid_name(name: &str) -> bool { !name.is_empty() @@ -60,19 +100,19 @@ mod tests { let mut providers = BTreeMap::new(); providers.insert( "openai".to_string(), - ProviderDef { + Some(ProviderDef { base_url: "https://api.openai.com/v1".to_string(), default_model: Some("gpt-5.2".to_string()), models: vec!["gpt-5.2".to_string(), "gpt-5.1-mini".to_string()], - }, + }), ); providers.insert( "deepseek".to_string(), - ProviderDef { + Some(ProviderDef { base_url: "https://api.deepseek.com".to_string(), default_model: None, models: vec!["deepseek-chat".to_string()], - }, + }), ); c.settings.providers = Some(providers); c @@ -114,4 +154,44 @@ mod tests { assert!(!is_valid_name("")); assert!(!is_valid_name("has space")); } + + #[test] + fn resolve_for_flag_beats_agent_beats_default() { + let c = config_with(); + // 1. Rien de précisé → default_provider (openai) + son default_model. + let (p, _, m) = resolve_for(&c, None, None, None, None).unwrap(); + assert_eq!(p, "openai"); + assert_eq!(m, Some("gpt-5.2")); + // 2. Le provider déclaré par l'agent gagne sur le défaut. + let (p, _, m) = resolve_for(&c, Some("deepseek"), None, None, None).unwrap(); + assert_eq!(p, "deepseek"); + assert_eq!(m, None, "deepseek n'a pas de default_model"); + // 3. Le flag --provider gagne sur tout. + let (p, _, m) = resolve_for(&c, Some("openai"), None, Some("deepseek"), None).unwrap(); + assert_eq!(p, "deepseek"); + // 4. --model flag > agent.model > default_model. + let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, None).unwrap(); + assert_eq!(m, Some("o4-mini")); + let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, Some("gpt-5.1-mini")).unwrap(); + assert_eq!(m, Some("gpt-5.1-mini")); + } + + #[test] + fn resolve_for_none_without_any_provider() { + let mut c = config_with(); + c.settings.default_provider = None; + c.settings.providers = None; + assert!(resolve_for(&c, None, None, None, None).is_none()); + // Un agent qui déclare son provider résout même sans défaut. + assert!(resolve_for(&c, Some("openai"), None, None, None).is_none()); + let mut c2 = config_with(); + c2.settings.default_provider = None; + assert_eq!(resolve_for(&c2, Some("openai"), None, None, None).unwrap().0, "openai"); + } + + #[test] + fn names_are_sorted() { + let c = config_with(); + assert_eq!(names(&c), vec!["deepseek".to_string(), "openai".to_string()]); + } } diff --git a/src/repl.rs b/src/repl.rs index b714e38..eddaf4c 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -1753,6 +1753,9 @@ fn handle_line( agent: need("agent name")?, method: method_flag(), force: flag("--force"), + provider: opt_value("--provider"), + model: opt_value("--model"), + no_config: flag("--no-config"), }, "uninstall" => Command::Uninstall { agent: need("agent name")?, diff --git a/tests/dry_run_test.rs b/tests/dry_run_test.rs index c35a443..a81c007 100644 --- a/tests/dry_run_test.rs +++ b/tests/dry_run_test.rs @@ -14,7 +14,7 @@ fn install_dry_run_creates_nothing() { &["install", "smelt", "--dry-run", "--yes"], ); assert!(app.dry_run()); - let code = install_cmd::run(&app, "smelt", None, false).unwrap(); + let code = install_cmd::run(&app, "smelt", None, false, None, None, false).unwrap(); assert_eq!(code, 0); // Nothing was written. assert!(!app.paths.install_dir.join("smelt").exists());