agentzero_config/
loader.rs1use 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
203fn 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
250pub 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}