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
+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,