//! 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) { apply_env_map_resolved(env, agent, config, None, None); } /// Same as `apply_env_map` but with the provider and model fixed by the /// caller (issue #92: `am run/start --provider/--model`): the flags win /// over the agent declaration and the settings default. pub fn apply_env_map_resolved( env: &mut BTreeMap, agent: &AgentDef, config: &Config, provider_override: Option<&str>, model_override: Option<&str>, ) { let Some(cfg) = &agent.config else { return; }; if cfg.env_map.is_empty() { return; } let Some(pname) = provider_override .filter(|p| !p.is_empty()) .or_else(|| provider_pref(agent)) .or_else(|| crate::providers::default_name(config)) else { return; }; let Some(def) = crate::providers::get(config, pname) else { return; }; let model = model_override .filter(|m| !m.is_empty()) .or(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" => { let Some(m) = model else { continue }; m.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()), detect: None, args: vec![], env: BTreeMap::new(), version: None, pin_version: None, model_env: None, model_arg: None, provider, model: None, config: Some(config), sandbox: None, 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")); } }