Skip to main content

agentzero_config/
loader.rs

1use crate::model::AgentZeroConfig;
2use agentzero_core::common::local_providers::local_provider_meta;
3use anyhow::{anyhow, Context};
4use config::{Config, Environment, File};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8pub fn load(path: &Path) -> anyhow::Result<AgentZeroConfig> {
9    let dotenv_overrides = load_dotenv_chain(path)?;
10    let settings = Config::builder()
11        .add_source(File::from(path.to_path_buf()).required(false))
12        .add_source(
13            Environment::with_prefix("AGENTZERO")
14                .separator("__")
15                .list_separator(",")
16                .try_parsing(true),
17        )
18        .build()
19        .context("failed to build layered config")?;
20
21    let parsed: AgentZeroConfig = settings
22        .try_deserialize()
23        .context("failed to deserialize config into typed model")?;
24    let config = apply_dotenv_overrides(parsed, &dotenv_overrides)?;
25    let mut config = apply_legacy_env_overrides(config)?;
26    normalize_base_url(&mut config);
27    resolve_local_provider_defaults(&mut config);
28    config.validate()?;
29    Ok(config)
30}
31
32pub fn load_env_var(path: &Path, key: &str) -> anyhow::Result<Option<String>> {
33    if let Ok(value) = std::env::var(key) {
34        let trimmed = value.trim();
35        if !trimmed.is_empty() {
36            return Ok(Some(trimmed.to_string()));
37        }
38    }
39
40    let dotenv_overrides = load_dotenv_chain(path)?;
41    Ok(first_nonempty_value(&dotenv_overrides, &[key]))
42}
43
44fn load_dotenv_chain(config_path: &Path) -> anyhow::Result<HashMap<String, String>> {
45    let mut out = HashMap::new();
46    let root = config_path
47        .parent()
48        .map(Path::to_path_buf)
49        .unwrap_or_else(|| PathBuf::from("."));
50
51    for file in [root.join(".env"), root.join(".env.local")] {
52        if !file.exists() {
53            continue;
54        }
55        for entry in dotenvy::from_path_iter(&file)
56            .with_context(|| format!("failed to read dotenv file at {}", file.display()))?
57        {
58            let (key, value) = entry
59                .with_context(|| format!("failed to parse dotenv entry in {}", file.display()))?;
60            out.insert(key, value);
61        }
62    }
63
64    if let Some(env) = first_nonempty_env(&["AGENTZERO_ENV", "APP_ENV", "NODE_ENV"])
65        .or_else(|| first_nonempty_value(&out, &["AGENTZERO_ENV", "APP_ENV", "NODE_ENV"]))
66    {
67        let file = root.join(format!(".env.{env}"));
68        if file.exists() {
69            for entry in dotenvy::from_path_iter(&file)
70                .with_context(|| format!("failed to read dotenv file at {}", file.display()))?
71            {
72                let (key, value) = entry.with_context(|| {
73                    format!("failed to parse dotenv entry in {}", file.display())
74                })?;
75                out.insert(key, value);
76            }
77        }
78    }
79
80    Ok(out)
81}
82
83fn apply_dotenv_overrides(
84    mut config: AgentZeroConfig,
85    dotenv_overrides: &HashMap<String, String>,
86) -> anyhow::Result<AgentZeroConfig> {
87    if let Some(value) = first_nonempty_value(dotenv_overrides, &["AGENTZERO_PROVIDER__KIND"]) {
88        config.provider.kind = value;
89    }
90    if let Some(value) = first_nonempty_value(dotenv_overrides, &["AGENTZERO_PROVIDER__BASE_URL"]) {
91        config.provider.base_url = value;
92    }
93    if let Some(value) = first_nonempty_value(dotenv_overrides, &["AGENTZERO_PROVIDER__MODEL"]) {
94        config.provider.model = value;
95    }
96    if let Some(value) = first_nonempty_value(dotenv_overrides, &["AGENTZERO_MEMORY__BACKEND"]) {
97        config.memory.backend = value;
98    }
99    if let Some(value) = first_nonempty_value(dotenv_overrides, &["AGENTZERO_MEMORY__SQLITE_PATH"])
100    {
101        config.memory.sqlite_path = value;
102    }
103    if let Some(value) =
104        first_nonempty_value(dotenv_overrides, &["AGENTZERO_AGENT__MEMORY_WINDOW_SIZE"])
105    {
106        config.agent.memory_window_size = value.parse().with_context(|| {
107            "AGENTZERO_AGENT__MEMORY_WINDOW_SIZE must be a positive integer".to_string()
108        })?;
109    }
110    if let Some(value) =
111        first_nonempty_value(dotenv_overrides, &["AGENTZERO_AGENT__MAX_PROMPT_CHARS"])
112    {
113        config.agent.max_prompt_chars = value.parse().with_context(|| {
114            "AGENTZERO_AGENT__MAX_PROMPT_CHARS must be a positive integer".to_string()
115        })?;
116    }
117    if let Some(value) =
118        first_nonempty_value(dotenv_overrides, &["AGENTZERO_SECURITY__ALLOWED_ROOT"])
119    {
120        config.security.allowed_root = value;
121    }
122    if let Some(value) =
123        first_nonempty_value(dotenv_overrides, &["AGENTZERO_SECURITY__ALLOWED_COMMANDS"])
124    {
125        let commands = value
126            .split(',')
127            .map(str::trim)
128            .filter(|part| !part.is_empty())
129            .map(ToString::to_string)
130            .collect::<Vec<_>>();
131        if !commands.is_empty() {
132            config.security.allowed_commands = commands;
133        } else {
134            return Err(anyhow!(
135                "AGENTZERO_SECURITY__ALLOWED_COMMANDS must contain at least one command when set"
136            ));
137        }
138    }
139
140    Ok(config)
141}
142
143fn apply_legacy_env_overrides(mut config: AgentZeroConfig) -> anyhow::Result<AgentZeroConfig> {
144    if let Some(value) = first_nonempty_env(&["AGENTZERO_PROVIDER", "AGENTZERO_PROVIDER__KIND"]) {
145        config.provider.kind = value;
146    }
147    if let Some(value) = first_nonempty_env(&["AGENTZERO_BASE_URL", "AGENTZERO_PROVIDER__BASE_URL"])
148    {
149        config.provider.base_url = value;
150    }
151    if let Some(value) = first_nonempty_env(&["AGENTZERO_MODEL", "AGENTZERO_PROVIDER__MODEL"]) {
152        config.provider.model = value;
153    }
154    if let Some(value) =
155        first_nonempty_env(&["AGENTZERO_MEMORY_BACKEND", "AGENTZERO_MEMORY__BACKEND"])
156    {
157        config.memory.backend = value;
158    }
159    if let Some(value) =
160        first_nonempty_env(&["AGENTZERO_MEMORY_PATH", "AGENTZERO_MEMORY__SQLITE_PATH"])
161    {
162        config.memory.sqlite_path = value;
163    }
164    if let Some(value) = first_nonempty_env(&["AGENTZERO_AGENT__MEMORY_WINDOW_SIZE"]) {
165        config.agent.memory_window_size = value.parse().with_context(|| {
166            "AGENTZERO_AGENT__MEMORY_WINDOW_SIZE must be a positive integer".to_string()
167        })?;
168    }
169    if let Some(value) = first_nonempty_env(&["AGENTZERO_AGENT__MAX_PROMPT_CHARS"]) {
170        config.agent.max_prompt_chars = value.parse().with_context(|| {
171            "AGENTZERO_AGENT__MAX_PROMPT_CHARS must be a positive integer".to_string()
172        })?;
173    }
174    if let Some(value) =
175        first_nonempty_env(&["AGENTZERO_ALLOWED_ROOT", "AGENTZERO_SECURITY__ALLOWED_ROOT"])
176    {
177        config.security.allowed_root = value;
178    }
179    if let Some(value) = first_nonempty_env(&[
180        "AGENTZERO_ALLOWED_COMMANDS",
181        "AGENTZERO_SECURITY__ALLOWED_COMMANDS",
182    ]) {
183        let commands = value
184            .split(',')
185            .map(str::trim)
186            .filter(|part| !part.is_empty())
187            .map(ToString::to_string)
188            .collect::<Vec<_>>();
189        if !commands.is_empty() {
190            config.security.allowed_commands = commands;
191        } else {
192            return Err(anyhow!(
193                "AGENTZERO_ALLOWED_COMMANDS must contain at least one command when set"
194            ));
195        }
196    }
197
198    Ok(config)
199}
200
201const DEFAULT_CLOUD_BASE_URL: &str = "https://openrouter.ai/api";
202
203/// Strip a trailing `/v1` (or `/v1/`) from `base_url` so the provider code
204/// can unconditionally append `/v1/chat/completions` without doubling the
205/// version prefix.  This keeps backwards compatibility for configs that
206/// already include `/v1` in their `base_url`.
207fn normalize_base_url(config: &mut AgentZeroConfig) {
208    let trimmed = config.provider.base_url.trim_end_matches('/');
209    if let Some(stripped) = trimmed.strip_suffix("/v1") {
210        config.provider.base_url = stripped.to_string();
211    }
212}
213
214fn resolve_local_provider_defaults(config: &mut AgentZeroConfig) {
215    if let Some(meta) = local_provider_meta(&config.provider.kind) {
216        let is_default_url = config.provider.base_url == DEFAULT_CLOUD_BASE_URL
217            || config.provider.base_url.trim().is_empty();
218        if is_default_url {
219            config.provider.base_url = meta.default_base_url.to_string();
220        }
221    }
222}
223
224fn first_nonempty_env(keys: &[&str]) -> Option<String> {
225    keys.iter().find_map(|key| {
226        std::env::var(key).ok().and_then(|value| {
227            let trimmed = value.trim();
228            if trimmed.is_empty() {
229                None
230            } else {
231                Some(trimmed.to_string())
232            }
233        })
234    })
235}
236
237fn first_nonempty_value(values: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
238    keys.iter().find_map(|key| {
239        values.get(*key).and_then(|value| {
240            let trimmed = value.trim();
241            if trimmed.is_empty() {
242                None
243            } else {
244                Some(trimmed.to_string())
245            }
246        })
247    })
248}
249
250/// Update the `[autonomy].auto_approve` list in a config TOML file on disk.
251///
252/// Reads the existing file (or starts from an empty doc), merges the new list,
253/// and writes back. This preserves all other config sections.
254pub fn update_auto_approve(path: &Path, tools: &[String]) -> anyhow::Result<()> {
255    let content = if path.exists() {
256        std::fs::read_to_string(path).context("failed to read config file for update")?
257    } else {
258        String::new()
259    };
260
261    let mut doc: toml::Table =
262        toml::from_str(&content).context("failed to parse config TOML for update")?;
263
264    let autonomy = doc
265        .entry("autonomy")
266        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
267
268    if let toml::Value::Table(table) = autonomy {
269        let arr = tools
270            .iter()
271            .map(|t| toml::Value::String(t.clone()))
272            .collect();
273        table.insert("auto_approve".to_string(), toml::Value::Array(arr));
274    }
275
276    let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
277    std::fs::write(path, serialized).context("failed to write updated config file")?;
278
279    Ok(())
280}