feat: v0.7.0 — configuration post-install : bloc config: par agent (env_map + fichiers), adaptateurs TOML/YAML/JSON/key=value, édition douce, jamais de token en clair (api_key = @secret) (closes #91)
- 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)
This commit is contained in:
Generated
+60
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
@@ -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<FileFormat> {
|
||||
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<String, String>, 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<String>)>,
|
||||
) -> 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<String> {
|
||||
let mut root: toml::Value = if text.trim().is_empty() {
|
||||
toml::Value::Table(Default::default())
|
||||
} else {
|
||||
text.parse::<toml::Value>()
|
||||
.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<String> {
|
||||
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<String> {
|
||||
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<String> = 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<String>) -> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<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());
|
||||
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,
|
||||
|
||||
+4
-3
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -342,6 +342,9 @@ pub struct AgentDef {
|
||||
/// provider's default_model when unset.
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// Post-install provider configuration: env_map + files (issue #91).
|
||||
#[serde(default)]
|
||||
pub config: Option<AgentConfig>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 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<String, String>,
|
||||
/// Configuration files to write or softly edit after the install.
|
||||
pub files: Vec<AgentConfigFile>,
|
||||
/// Provider of this agent, otherwise settings.default_provider.
|
||||
pub provider_default: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
|
||||
@@ -185,6 +185,13 @@ pub const CATALOG: &[(&str, &str)] = &[
|
||||
("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 {})"),
|
||||
("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 <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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user