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:
2026-08-19 20:41:34 -04:00
parent ebc753cc4a
commit 68eb170f57
11 changed files with 627 additions and 6 deletions
Generated
+60
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+493
View File
@@ -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"));
}
}
+10 -2
View File
@@ -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
View File
@@ -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,
);
+1
View File
@@ -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,
+30
View File
@@ -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 {
+7
View File
@@ -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"),
+1
View File
@@ -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;