feat: v0.7.0 — AgentDef provider/model + install --provider/--model/--no-config : résolution flag > agent > défaut, erreur si provider inconnu (liste du registre), warning si modèle absent, résolution affichée en résumé et au --dry-run (closes #90)

- Providers réels livrés dans le catalogue par défaut (anthropic★/openai/deepseek/google) + hints provider sur claude-code/deepseek-harness
- Suppression par null dans l'overlay : am providers remove écrit <nom>: null (merge traite null comme suppression, set_path remplace le null avant de descendre)
- Cascade @secret (#89) : le provider déclaré de l'agent prime sur default_provider
This commit is contained in:
2026-08-19 20:03:47 -04:00
parent 7312e6ceab
commit ebc753cc4a
14 changed files with 336 additions and 38 deletions
+1 -1
View File
@@ -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 |
+24 -12
View File
@@ -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/<nom>/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/<nom>/api_key (issue #89, am providers set-token).
# Pour retirer un provider du catalogue par défaut : am providers remove <nom>
# (écrit "<nom>: 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
+10 -1
View File
@@ -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<PROVIDER>\fR
Provider to configure (default: the agent\*(Aqs provider, else settings.default_provider) (issue #90)
.TP
\fB\-\-model\fR \fI<MODEL>\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
+9
View File
@@ -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<String>,
/// Model to configure (default: the provider's default model) (issue #90)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Skip the post-install provider configuration (issue #91)
#[arg(long)]
no_config: bool,
},
/// Uninstall an agent (managed or external) and clean its files
Uninstall {
+112 -2
View File
@@ -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<i32> {
pub fn run(
app: &App,
agent_name: &str,
method_sel: Option<&str>,
force: bool,
provider: Option<&str>,
model: Option<&str>,
no_config: bool,
) -> Result<i32> {
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<Option<(String, Option<String>)>> {
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 <nom> --base-url <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 <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 <nom> --base-url <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))))
}
+16 -2
View File
@@ -169,7 +169,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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 {
+25 -7
View File
@@ -38,15 +38,19 @@ pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
fn list(app: &App) -> Result<i32> {
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<serde_json::Value> = providers
let rows: Vec<serde_json::Value> = 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 <nom> --base-url <url>",
));
@@ -58,7 +62,7 @@ fn list(app: &App) -> Result<i32> {
"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<i32> {
}
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());
}
}
+2
View File
@@ -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,
+32 -4
View File
@@ -148,7 +148,7 @@ pub struct Settings {
/// they live in the OS keyring under `providers/<name>/api_key`
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, ProviderDef>>,
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
}
/// 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<String>,
/// 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<String>,
/// Preferred model for this agent (issue #90). Falls back to the
/// provider's default_model when unset.
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// 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<String>,
/// 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<String> {
}
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"));
}
+8 -2
View File
@@ -797,11 +797,14 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "install",
category: "Commands",
usage: "install {flags} <agent>",
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 {
+7
View File
@@ -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 <url>", "provider '{}' (declared by {}) is not registered — see: am providers add {} --base-url <url>"),
("aucun provider configuré — voir: am providers add <nom> --base-url <url>", "no provider configured — see: am providers add <name> --base-url <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 <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>", "usage: secret set <name> --agent <agent> --value <value> | secret set <name> --provider <provider> --value <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"),
+86 -6
View File
@@ -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<String, ProviderDef> = BTreeMap::new();
static EMPTY: BTreeMap<String, Option<ProviderDef>> = BTreeMap::new();
/// Every provider of the effective configuration (empty when unset).
pub fn all(config: &Config) -> &BTreeMap<String, ProviderDef> {
/// `None` values are the YAML null-deletion markers and are skipped.
pub fn all(config: &Config) -> &BTreeMap<String, Option<ProviderDef>> {
config.settings.providers.as_ref().unwrap_or(&EMPTY)
}
/// The providers actually defined (null markers filtered out).
pub fn defined(config: &Config) -> impl Iterator<Item = (&String, &ProviderDef)> {
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<String> {
let mut v: Vec<String> = 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()]);
}
}
+3
View File
@@ -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")?,
+1 -1
View File
@@ -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());