From 68eb170f57a64130cdee43a7ce96dc09ef429243 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 19 Aug 2026 20:41:34 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v0.7.0=20=E2=80=94=20configuration=20po?= =?UTF-8?q?st-install=20:=20bloc=20config:=20par=20agent=20(env=5Fmap=20+?= =?UTF-8?q?=20fichiers),=20adaptateurs=20TOML/YAML/JSON/key=3Dvalue,=20?= =?UTF-8?q?=C3=A9dition=20douce,=20jamais=20de=20token=20en=20clair=20(api?= =?UTF-8?q?=5Fkey=20=3D=20@secret)=20(closes=20#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau module src/agent_config.rs : apply_post_install (écriture fichiers), apply_env_map (env injectée au start/run), provider_pref (provider > config.provider_default > default_provider) - am install écrit les fichiers de config de l'agent avec le provider/modèle résolus ; --no-config / install.configurable:false / --dry-run respectés ; agent sans adaptateur → message clair, install réussit - Catalogue : blocs config: pour claude-code, codex, agentty (+ provider_default) --- Cargo.lock | 60 +++++ Cargo.toml | 1 + ROADMAP.md | 2 +- config.yaml | 19 ++ src/agent_config.rs | 493 ++++++++++++++++++++++++++++++++++++ src/commands/install_cmd.rs | 12 +- src/commands/mod.rs | 7 +- src/commands/suggest_cmd.rs | 1 + src/config.rs | 30 +++ src/i18n.rs | 7 + src/lib.rs | 1 + 11 files changed, 627 insertions(+), 6 deletions(-) create mode 100644 src/agent_config.rs diff --git a/Cargo.lock b/Cargo.lock index 3c17d88..5965665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,7 @@ dependencies = [ "tar", "tempfile", "tiny_http", + "toml", "ureq", "wait-timeout", "which", @@ -1900,6 +1901,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2314,6 +2324,47 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.1" @@ -2865,6 +2916,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index ddcf128..87b9e8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ shell-words = "1" tar = "0.4" tempfile = "3" tiny_http = "0.12" +toml = "0.8" ureq = { version = "2", default-features = false, features = ["tls"] } wait-timeout = "0.2" keyring = { version = "3", features = ["windows-native", "linux-native"] } diff --git a/ROADMAP.md b/ROADMAP.md index 19a8d5a..b613032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -500,7 +500,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 | -| [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 | +| [#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 | Critère de sortie du jalon : un agent installé est opérationnel sans diff --git a/config.yaml b/config.yaml index dc00a2c..6086f05 100644 --- a/config.yaml +++ b/config.yaml @@ -103,6 +103,11 @@ agents: - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } run: claude provider: anthropic + config: + env_map: + api_key: ANTHROPIC_API_KEY + model: ANTHROPIC_MODEL + base_url: ANTHROPIC_BASE_URL tags: [anthropic, assistant, source-available, mainstream] - name: deepseek-harness @@ -257,6 +262,11 @@ agents: dependencies: - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } run: codex + config: + env_map: + api_key: OPENAI_API_KEY + model: OPENAI_MODEL + base_url: OPENAI_BASE_URL tags: [openai, mainstream] - name: openhands @@ -507,6 +517,15 @@ agents: repo: 1ay1/agentty binary: agentty run: agentty + config: + provider_default: openai + env_map: + api_key: AGENTTY_API_KEY + model: AGENTTY_MODEL + base_url: AGENTTY_BASE_URL + files: + - path: ~/.config/agentty/config.toml + keys: [model, base_url] tags: [c++, static, sandbox] - name: nullclaw diff --git a/src/agent_config.rs b/src/agent_config.rs new file mode 100644 index 0000000..a2307e2 --- /dev/null +++ b/src/agent_config.rs @@ -0,0 +1,493 @@ +//! Post-install agent configuration (issue #91): the `config:` block of an +//! agent definition wires the resolved provider/model into the agent — +//! the environment variables it expects (env_map) and the config files to +//! write or softly edit (files). +//! +//! Pure functions + per-format tests (probe.rs pattern). Security rule: a +//! token is never written in clear — the api_key slot stays the `@secret` +//! reference (issue #89), resolved from the OS keyring at start/run time. + +use crate::app::App; +use crate::config::{AgentDef, Config}; +use anyhow::{anyhow, Context, Result}; +use std::collections::BTreeMap; +use std::path::Path; + +/// The four format families covering ~every agent (spec #91). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileFormat { + Toml, + Yaml, + Json, + KeyValue, +} + +impl FileFormat { + pub fn name(self) -> &'static str { + match self { + FileFormat::Toml => "toml", + FileFormat::Yaml => "yaml", + FileFormat::Json => "json", + FileFormat::KeyValue => "key=value", + } + } +} + +/// Detect the format family from the file extension. +pub fn format_of(path: &Path) -> Option { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "toml" => Some(FileFormat::Toml), + "yaml" | "yml" => Some(FileFormat::Yaml), + "json" => Some(FileFormat::Json), + "conf" | "ini" | "env" | "properties" | "cfg" => Some(FileFormat::KeyValue), + _ => None, + } +} + +/// Provider preferred by an agent: `provider:` of the definition, then the +/// `config.provider_default` of its config block (issue #91). +pub fn provider_pref(agent: &AgentDef) -> Option<&str> { + agent + .provider + .as_deref() + .or(agent.config.as_ref().and_then(|c| c.provider_default.as_deref())) +} + +/// Merge the resolved `config.env_map` of an agent into its default env +/// (issue #91): api_key -> `@secret` (resolved by resolve_env_secrets), +/// model/base_url/provider -> values of the resolved provider. Explicit +/// values already present in agent.env are never overwritten. +pub fn apply_env_map(env: &mut BTreeMap, agent: &AgentDef, config: &Config) { + let Some(cfg) = &agent.config else { + return; + }; + if cfg.env_map.is_empty() { + return; + } + let Some(pname) = provider_pref(agent).or_else(|| crate::providers::default_name(config)) else { + return; + }; + let Some(def) = crate::providers::get(config, pname) else { + return; + }; + let model = agent.model.as_deref().or(def.default_model.as_deref()); + for (slot, var) in &cfg.env_map { + let value = match slot.as_str() { + "api_key" => "@secret".to_string(), + "model" => model.unwrap_or_default().to_string(), + "base_url" => def.base_url.clone(), + "provider" => pname.to_string(), + _ => continue, // unknown slots are ignored + }; + env.entry(var.clone()).or_insert(value); + } +} + +/// Write the agent configuration files after a successful install +/// (issue #91). The caller skips this on `--no-config`, on non-configurable +/// agents, and in dry-run (never reached). Never writes a token in clear: +/// the api_key slot is written as the `@secret` reference. +pub fn apply_post_install( + app: &App, + agent: &AgentDef, + resolved: &Option<(String, Option)>, +) -> Result<()> { + let Some(cfg) = &agent.config else { + app.log.info(crate::i18n::tr( + "agent sans adaptateur de config — voir sa doc", + )); + return Ok(()); + }; + if cfg.files.is_empty() { + if cfg.env_map.is_empty() { + app.log.info(crate::i18n::tr( + "agent sans adaptateur de config — voir sa doc", + )); + } else { + app.log.info(crate::i18n::tr( + "config par variables d'env — injectée au démarrage", + )); + } + return Ok(()); + } + let Some((pname, model)) = resolved else { + app.log.warn(crate::i18n::tr( + "aucun provider résolu — fichiers de config non écrits", + )); + return Ok(()); + }; + let Some(def) = crate::providers::get(&app.config, pname) else { + app.log.warn(&crate::tr_fmt!( + "provider '{}' introuvable — fichiers de config non écrits", + pname + )); + return Ok(()); + }; + for f in &cfg.files { + let path = crate::config::expand_path(&f.path); + let Some(fmt) = format_of(&path) else { + app.log.warn(&crate::tr_fmt!( + "format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", + path.display() + )); + continue; + }; + let mut updates: Vec<(String, String)> = Vec::new(); + for key in &f.keys { + let value = match key.as_str() { + "api_key" => "@secret".to_string(), + "model" => model.clone().unwrap_or_default(), + "base_url" => def.base_url.clone(), + "provider" => pname.clone(), + other => { + app.log.warn(&crate::tr_fmt!( + "clé '{}' inconnue dans config.files de {}", + other, + agent.name + )); + continue; + } + }; + updates.push((key.clone(), value)); + } + write_soft(&path, fmt, &updates)?; + app.log + .success(&crate::tr_fmt!("config écrite: {}", path.display())); + } + Ok(()) +} + +/// Soft edit of a config file: existing unknown keys are preserved, known +/// keys are updated, missing keys are appended. Creates parent dirs. +pub fn write_soft(path: &Path, format: FileFormat, updates: &[(String, String)]) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + } + let text = if path.exists() { + std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))? + } else { + String::new() + }; + let out = match format { + FileFormat::Toml => edit_toml(&text, updates)?, + FileFormat::Yaml => edit_yaml(&text, updates)?, + FileFormat::Json => edit_json(&text, updates)?, + FileFormat::KeyValue => edit_key_value(&text, updates), + }; + std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?; + Ok(()) +} + +/// TOML soft edit. Note: toml::Value does not retain comments on +/// serialization — unknown KEYS are preserved, comments are dropped. +fn edit_toml(text: &str, updates: &[(String, String)]) -> Result { + let mut root: toml::Value = if text.trim().is_empty() { + toml::Value::Table(Default::default()) + } else { + text.parse::() + .map_err(|e| anyhow!("invalid TOML: {e}"))? + }; + let table = root + .as_table_mut() + .ok_or_else(|| anyhow!("TOML root is not a table"))?; + for (k, v) in updates { + table.insert(k.clone(), toml::Value::String(v.clone())); + } + Ok(root.to_string()) +} + +fn edit_yaml(text: &str, updates: &[(String, String)]) -> Result { + let mut root: serde_yaml::Value = if text.trim().is_empty() { + serde_yaml::Value::Mapping(Default::default()) + } else { + serde_yaml::from_str(text).map_err(|e| anyhow!("invalid YAML: {e}"))? + }; + let map = root + .as_mapping_mut() + .ok_or_else(|| anyhow!("YAML root is not a mapping"))?; + for (k, v) in updates { + map.insert( + serde_yaml::Value::String(k.clone()), + serde_yaml::Value::String(v.clone()), + ); + } + serde_yaml::to_string(&root).map_err(|e| anyhow!("cannot serialize YAML: {e}")) +} + +fn edit_json(text: &str, updates: &[(String, String)]) -> Result { + let mut root: serde_json::Value = if text.trim().is_empty() { + serde_json::Value::Object(Default::default()) + } else { + serde_json::from_str(text).map_err(|e| anyhow!("invalid JSON: {e}"))? + }; + let obj = root + .as_object_mut() + .ok_or_else(|| anyhow!("JSON root is not an object"))?; + for (k, v) in updates { + obj.insert(k.clone(), serde_json::Value::String(v.clone())); + } + serde_json::to_string_pretty(&root).map_err(|e| anyhow!("cannot serialize JSON: {e}")) +} + +fn edit_key_value(text: &str, updates: &[(String, String)]) -> String { + let mut lines: Vec = text.lines().map(String::from).collect(); + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for (i, line) in lines.iter_mut().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed.starts_with(';') + || !trimmed.contains('=') + { + continue; + } + let key = trimmed.split('=').next().unwrap_or("").trim(); + // Case-insensitive match (API_KEY vs api_key in .env files); the + // existing line keeps its casing. + if let Some((k, v)) = updates.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) { + *line = format!("{key}={v}"); + seen.insert(k, i); + } + } + for (k, v) in updates { + if !seen.contains_key(k.as_str()) { + lines.push(format!("{k}={v}")); + } + } + let mut out = lines.join("\n"); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use crate::config::{AgentConfig, Config}; + use std::collections::BTreeMap; + + fn updates() -> Vec<(String, String)> { + vec![ + ("model".to_string(), "gpt-5.2".to_string()), + ("base_url".to_string(), "https://api.openai.com/v1".to_string()), + ("api_key".to_string(), "@secret".to_string()), + ] + } + + fn tmp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join(name); + if !content.is_empty() { + std::fs::write(&p, content).unwrap(); + } + (dir, p) + } + + #[test] + fn format_detection_covers_the_four_families() { + assert_eq!(format_of(Path::new("a.toml")), Some(FileFormat::Toml)); + assert_eq!(format_of(Path::new("a.yaml")), Some(FileFormat::Yaml)); + assert_eq!(format_of(Path::new("a.yml")), Some(FileFormat::Yaml)); + assert_eq!(format_of(Path::new("a.json")), Some(FileFormat::Json)); + assert_eq!(format_of(Path::new("a.conf")), Some(FileFormat::KeyValue)); + assert_eq!(format_of(Path::new("a.env")), Some(FileFormat::KeyValue)); + assert_eq!(format_of(Path::new("a.ini")), Some(FileFormat::KeyValue)); + assert_eq!(format_of(Path::new("a.md")), None); + } + + #[test] + fn toml_round_trip_keeps_unknown_keys() { + let (_d, p) = tmp("c.toml", "# existing\nmodel = \"old\"\ntemperature = 0.7\n"); + write_soft(&p, FileFormat::Toml, &updates()).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains("model = \"gpt-5.2\""), "{out}"); + assert!( + out.contains("base_url = \"https://api.openai.com/v1\""), + "{out}" + ); + assert!(out.contains("api_key = \"@secret\""), "{out}"); + assert!(out.contains("temperature = 0.7"), "unknown key lost: {out}"); + } + + #[test] + fn toml_creates_the_file_when_missing() { + let (_d, p) = tmp("new.toml", ""); + write_soft(&p, FileFormat::Toml, &updates()).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains("model = \"gpt-5.2\""), "{out}"); + assert!(out.contains("api_key = \"@secret\""), "{out}"); + } + + #[test] + fn yaml_round_trip_keeps_unknown_keys() { + let (_d, p) = tmp("c.yaml", "model: old\nverbose: true\n"); + write_soft(&p, FileFormat::Yaml, &updates()).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains("model: gpt-5.2"), "{out}"); + assert!(out.contains("base_url: https://api.openai.com/v1"), "{out}"); + assert!(out.contains("verbose: true"), "unknown key lost: {out}"); + } + + #[test] + fn json_round_trip_keeps_unknown_keys() { + let (_d, p) = tmp("c.json", "{\"model\": \"old\", \"verbose\": true}"); + write_soft(&p, FileFormat::Json, &updates()).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains("\"model\": \"gpt-5.2\""), "{out}"); + assert!(out.contains("\"api_key\": \"@secret\""), "{out}"); + assert!(out.contains("\"verbose\": true"), "unknown key lost: {out}"); + } + + #[test] + fn key_value_round_trip_keeps_unknown_lines() { + let (_d, p) = tmp( + "c.conf", + "# header\nMODEL=old\nOTHER=keep\n", + ); + write_soft(&p, FileFormat::KeyValue, &updates()).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains("MODEL=gpt-5.2"), "{out}"); + assert!(out.contains("OTHER=keep"), "unknown line lost: {out}"); + assert!(out.contains("# header"), "comment lost: {out}"); + assert!(out.contains("api_key=@secret"), "{out}"); + // No duplicate MODEL line. + assert_eq!(out.matches("MODEL=").count(), 1, "{out}"); + } + + #[test] + fn key_value_appends_missing_keys() { + let (_d, p) = tmp("empty.env", ""); + write_soft(&p, FileFormat::KeyValue, &updates()).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains("model=gpt-5.2"), "{out}"); + assert!(out.contains("base_url=https://api.openai.com/v1"), "{out}"); + assert!(out.contains("api_key=@secret"), "{out}"); + } + + fn agent_with_config(config: AgentConfig, provider: Option) -> AgentDef { + AgentDef { + name: "x".to_string(), + display_name: None, + description: None, + category: None, + website: None, + install: None, + dependencies: vec![], + run: Some("x".to_string()), + args: vec![], + env: BTreeMap::new(), + version: None, + pin_version: None, + model_env: None, + model_arg: None, + provider, + model: None, + config: Some(config), + tags: vec![], + installable: false, + note: None, + hidden: false, + platforms: vec![], + healthcheck: None, + container: None, + cost_model: None, + } + } + + #[test] + fn apply_env_map_wires_the_resolved_provider() { + let mut c: Config = serde_yaml::from_str( + "version: \"1.0\"\nsettings:\n default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n", + ) + .unwrap(); + let mut env_map = BTreeMap::new(); + env_map.insert("api_key".to_string(), "MY_KEY".to_string()); + env_map.insert("model".to_string(), "MY_MODEL".to_string()); + env_map.insert("base_url".to_string(), "MY_URL".to_string()); + let agent = agent_with_config( + AgentConfig { + env_map, + files: vec![], + provider_default: None, + }, + None, + ); + let mut env = BTreeMap::new(); + apply_env_map(&mut env, &agent, &c); + assert_eq!(env.get("MY_KEY").unwrap(), "@secret"); + assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2"); + assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1"); + // Explicit agent.env values win. + let mut env2 = BTreeMap::new(); + env2.insert("MY_MODEL".to_string(), "explicit".to_string()); + apply_env_map(&mut env2, &agent, &c); + assert_eq!(env2.get("MY_MODEL").unwrap(), "explicit"); + // Unset default provider + no provider_default: no-op. + c.settings.default_provider = None; + let mut env3 = BTreeMap::new(); + apply_env_map(&mut env3, &agent, &c); + assert!(env3.is_empty()); + } + + #[test] + fn apply_post_install_without_config_block_is_a_noop() { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n", + ) + .unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let app = crate::app::App::from_cli(cli).unwrap(); + let agent = agent_with_config( + AgentConfig { + env_map: BTreeMap::new(), + files: vec![], + provider_default: None, + }, + None, + ); + // No config block at all → Ok, nothing written. + let mut bare = agent.clone(); + bare.config = None; + assert!(apply_post_install(&app, &bare, &None).is_ok()); + // Config block but no files → Ok, nothing written. + assert!(apply_post_install(&app, &agent, &None).is_ok()); + } + + #[test] + fn provider_pref_prefers_the_declared_provider() { + let agent = agent_with_config( + AgentConfig { + env_map: BTreeMap::new(), + files: vec![], + provider_default: Some("openai".to_string()), + }, + Some("anthropic".to_string()), + ); + assert_eq!(provider_pref(&agent), Some("anthropic")); + let agent2 = agent_with_config( + AgentConfig { + env_map: BTreeMap::new(), + files: vec![], + provider_default: Some("openai".to_string()), + }, + None, + ); + assert_eq!(provider_pref(&agent2), Some("openai")); + } +} diff --git a/src/commands/install_cmd.rs b/src/commands/install_cmd.rs index a616976..ab44247 100644 --- a/src/commands/install_cmd.rs +++ b/src/commands/install_cmd.rs @@ -157,6 +157,14 @@ pub fn run( } } + // Post-install provider configuration (issue #91): write the agent + // config files with the resolved provider/model. Skipped on --no-config + // and on agents declaring install.configurable: false; never reached in + // dry-run (returns earlier). + if !no_config && spec.configurable { + crate::agent_config::apply_post_install(app, agent, &resolved)?; + } + let entry = installers::make_entry(agent, method, &outcome, app); let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string()); app.state.set(&entry)?; @@ -207,7 +215,7 @@ fn resolve_provider_config( 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()); + let agent_p = crate::agent_config::provider_pref(agent).filter(|p| !p.is_empty()); // Explicit provider requested but not registered: hard error. if let Some(p) = flag_p { @@ -241,7 +249,7 @@ fn resolve_provider_config( let Some((pname, def, model)) = crate::providers::resolve_for( &app.config, - agent.provider.as_deref(), + crate::agent_config::provider_pref(agent), agent.model.as_deref(), flag_provider, flag_model, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 357f1cc..aa6a962 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -317,14 +317,15 @@ pub fn resolve_exec( args.extend(agent.args.iter().cloned()); args.extend(extra_args.iter().cloned()); let mut env = agent.env.clone(); + // Issue #91: the `config.env_map` block, resolved against the agent's + // provider — api_key stays the @secret reference (resolved below). + crate::agent_config::apply_env_map(&mut env, agent, &app.config); env.extend(extra_env.clone()); let warnings = crate::secrets::resolve_env_secrets( &crate::secrets::store(), &agent.name, - agent - .provider - .as_deref() + crate::agent_config::provider_pref(agent) .or_else(|| crate::providers::default_name(&app.config)), &mut env, ); diff --git a/src/commands/suggest_cmd.rs b/src/commands/suggest_cmd.rs index bc51607..5531ace 100644 --- a/src/commands/suggest_cmd.rs +++ b/src/commands/suggest_cmd.rs @@ -198,6 +198,7 @@ mod tests { model_arg: None, provider: None, model: None, + config: 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 52b9d9e..172a9a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -342,6 +342,9 @@ pub struct AgentDef { /// provider's default_model when unset. #[serde(default)] pub model: Option, + /// Post-install provider configuration: env_map + files (issue #91). + #[serde(default)] + pub config: Option, #[serde(default)] pub tags: Vec, /// When false, the agent is listed but cannot be installed locally. @@ -616,6 +619,33 @@ impl InstallSpec { } } +/// Post-install provider configuration of an agent (issue #91): the env vars +/// the agent expects and the config files to write or softly edit. The +/// api_key slot is always injected as the `@secret` reference (issue #89) — +/// a token is never written in clear. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct AgentConfig { + /// Expected env vars, keyed by semantic slot (api_key / model / + /// base_url / provider) -> the agent's variable name. + pub env_map: BTreeMap, + /// Configuration files to write or softly edit after the install. + pub files: Vec, + /// Provider of this agent, otherwise settings.default_provider. + pub provider_default: Option, +} + +/// One config file of an agent (issue #91). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentConfigFile { + /// File path (supports ~ and env vars). + pub path: String, + /// Keys to write among: api_key, model, base_url, provider. + #[serde(default)] + pub keys: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Dependency { diff --git a/src/i18n.rs b/src/i18n.rs index c8a0f5a..36d55c9 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -185,6 +185,13 @@ pub const CATALOG: &[(&str, &str)] = &[ ("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 {})"), + ("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"), + ("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"), + ("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"), + ("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"), + ("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"), + ("clé '{}' inconnue dans config.files de {}", "unknown key '{}' in config.files of {}"), + ("config écrite: {}", "config written: {}"), ("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/lib.rs b/src/lib.rs index 9f1ab5f..d588d34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ //! * commands — one module per CLI command. pub mod app; +pub mod agent_config; pub mod automation; pub mod backup; pub mod catalog;