1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use crate::event::AutonomyLevel;
6use crate::permissions::PermissionConfig;
7
8pub mod providers;
9pub mod validate;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Config {
14 #[serde(default)]
15 pub defaults: Defaults,
16 #[serde(default)]
17 pub routing: Routing,
18 #[serde(default)]
19 pub budget: Budget,
20 #[serde(default)]
21 pub providers: HashMap<String, ProviderConfig>,
22 #[serde(default)]
23 pub surfaces: SurfaceConfig,
24 #[serde(default)]
25 pub skills: SkillsConfig,
26 #[serde(default)]
27 pub permissions: PermissionConfig,
28 #[serde(default)]
29 pub hooks: Vec<crate::hooks::Hook>,
30 #[serde(default)]
31 pub theme: String,
32 #[serde(default = "default_config_dir")]
33 pub config_dir: PathBuf,
34 #[serde(default = "default_state_dir")]
35 pub state_dir: PathBuf,
36 #[serde(skip)]
37 pub forced_model: Option<(String, String)>,
38}
39
40fn default_config_dir() -> PathBuf {
41 dirs::config_dir()
42 .unwrap_or_else(|| PathBuf::from("."))
43 .join("sparrow")
44}
45
46fn default_state_dir() -> PathBuf {
47 dirs::state_dir()
48 .unwrap_or_else(|| PathBuf::from("."))
49 .join("sparrow")
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Defaults {
54 #[serde(default = "default_autonomy")]
55 pub autonomy: AutonomyLevel,
56 #[serde(default = "default_sandbox")]
57 pub sandbox: String,
58 #[serde(default = "default_theme")]
59 pub theme: String,
60 #[serde(default)]
63 pub verify_command: Option<String>,
64}
65
66impl Default for Defaults {
67 fn default() -> Self {
68 Self {
69 autonomy: default_autonomy(),
70 sandbox: default_sandbox(),
71 theme: default_theme(),
72 verify_command: None,
73 }
74 }
75}
76
77fn default_autonomy() -> AutonomyLevel {
78 AutonomyLevel::Trusted
79}
80fn default_sandbox() -> String {
81 "local-hardened".into()
82}
83fn default_theme() -> String {
84 "captain".into()
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Routing {
89 #[serde(default = "default_true")]
90 pub free_first: bool,
91 #[serde(default = "default_policy")]
92 pub policy: HashMap<String, String>,
93 #[serde(default = "default_on_budget")]
94 pub on_budget: String,
95 #[serde(default = "default_true")]
98 pub auto_discover: bool,
99 #[serde(default)]
104 pub preferred_provider: Option<String>,
105 #[serde(default)]
109 pub preferred_model: Option<String>,
110 #[serde(default = "default_routing_mode")]
114 pub routing_mode: String,
115}
116
117impl Default for Routing {
118 fn default() -> Self {
119 Self {
120 free_first: default_true(),
121 policy: default_policy(),
122 on_budget: default_on_budget(),
123 auto_discover: true,
124 preferred_provider: None,
125 preferred_model: None,
126 routing_mode: default_routing_mode(),
127 }
128 }
129}
130
131fn default_routing_mode() -> String {
132 "auto".into()
133}
134
135fn default_true() -> bool {
136 true
137}
138fn default_policy() -> HashMap<String, String> {
139 HashMap::from([
140 ("trivial".into(), "local".into()),
141 ("small".into(), "groq".into()),
142 ("medium".into(), "nvidia".into()),
143 ("hard".into(), "anthropic".into()),
144 ("vision".into(), "anthropic".into()),
145 ])
146}
147fn default_on_budget() -> String {
148 "downgrade".into()
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Budget {
153 #[serde(default = "default_five")]
154 pub daily_usd: f64,
155 #[serde(default = "default_one")]
156 pub session_usd: f64,
157}
158
159impl Default for Budget {
160 fn default() -> Self {
161 Self {
162 daily_usd: default_five(),
163 session_usd: default_one(),
164 }
165 }
166}
167
168fn default_five() -> f64 {
169 5.0
170}
171fn default_one() -> f64 {
172 1.0
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ProviderConfig {
177 pub adapter: String,
178 #[serde(default)]
179 pub base_url: Option<String>,
180 #[serde(default)]
181 pub models: Vec<String>,
182 #[serde(default)]
183 pub api_key_env: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct SurfaceConfig {
188 #[serde(default)]
189 pub telegram: Option<MessagingSurface>,
190 #[serde(default)]
191 pub discord: Option<MessagingSurface>,
192 #[serde(default)]
193 pub slack: Option<MessagingSurface>,
194 #[serde(default)]
195 pub email: Option<EmailSurface>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct EmailSurface {
200 pub enabled: bool,
201 pub from: String,
202 pub smtp_host: String,
203 #[serde(default = "default_smtp_port")]
204 pub smtp_port: u16,
205 pub username_env: String,
206 pub password_env: String,
207 #[serde(default)]
208 pub allowed_to: Vec<String>,
209 #[serde(default)]
211 pub imap_host: Option<String>,
212 #[serde(default = "default_imap_port")]
213 pub imap_port: u16,
214}
215
216fn default_smtp_port() -> u16 {
217 587
218}
219
220fn default_imap_port() -> u16 {
221 993
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct MessagingSurface {
226 pub enabled: bool,
227 #[serde(default)]
228 pub allow_users: Vec<String>,
229 #[serde(default)]
230 pub token_env: Option<String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct SkillsConfig {
235 #[serde(default = "default_skills_dir")]
236 pub dir: PathBuf,
237 #[serde(default = "default_curator_cron")]
238 pub curator_cron: String,
239}
240
241impl Default for SkillsConfig {
242 fn default() -> Self {
243 Self {
244 dir: default_skills_dir(),
245 curator_cron: default_curator_cron(),
246 }
247 }
248}
249
250fn default_skills_dir() -> PathBuf {
251 dirs::config_dir()
252 .unwrap_or_else(|| PathBuf::from("."))
253 .join("sparrow")
254 .join("skills")
255}
256
257fn default_curator_cron() -> String {
258 "0 */6 * * *".into()
259}
260
261impl Default for Config {
262 fn default() -> Self {
263 Self {
264 defaults: Defaults::default(),
265 routing: Routing::default(),
266 budget: Budget::default(),
267 providers: std::collections::HashMap::new(),
268 surfaces: SurfaceConfig::default(),
269 skills: SkillsConfig::default(),
270 permissions: PermissionConfig::default(),
271 hooks: Vec::new(),
272 theme: "captain".into(),
273 config_dir: default_config_dir(),
274 state_dir: default_state_dir(),
275 forced_model: None,
276 }
277 }
278}
279
280pub trait ConfigStore: Send + Sync {
284 fn load(&self) -> anyhow::Result<Config>;
285 fn save(&self, c: &Config) -> anyhow::Result<()>;
286}
287
288pub struct FsConfigStore {
290 config_dir: PathBuf,
291}
292
293impl FsConfigStore {
294 pub fn new(config_dir: PathBuf) -> Self {
295 Self { config_dir }
296 }
297
298 fn config_path(&self) -> PathBuf {
299 self.config_dir.join("config.toml")
300 }
301
302 fn apply_env_overrides(cfg: &mut Config) {
304 if let Ok(v) = std::env::var("SPARROW_DEFAULTS_AUTONOMY") {
306 if let Ok(level) = serde_json::from_str::<AutonomyLevel>(&format!("\"{}\"", v)) {
307 cfg.defaults.autonomy = level;
308 }
309 }
310 if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
312 cfg.defaults.sandbox = v;
313 }
314 if let Ok(v) = std::env::var("SPARROW_BUDGET_DAILY") {
316 if let Ok(amt) = v.parse::<f64>() {
317 cfg.budget.daily_usd = amt;
318 }
319 }
320 if let Ok(v) = std::env::var("SPARROW_BUDGET_SESSION") {
322 if let Ok(amt) = v.parse::<f64>() {
323 cfg.budget.session_usd = amt;
324 }
325 }
326 if let Ok(v) = std::env::var("SPARROW_THEME") {
328 if !v.trim().is_empty() {
329 cfg.theme = v;
330 }
331 }
332 }
333}
334
335impl ConfigStore for FsConfigStore {
336 fn load(&self) -> anyhow::Result<Config> {
337 let path = self.config_path();
338 let mut cfg = if path.exists() {
339 let content = std::fs::read_to_string(&path)?;
340 toml::from_str::<Config>(&content)?
341 } else {
342 let mut c = Config {
344 defaults: Defaults::default(),
345 routing: Routing::default(),
346 budget: Budget::default(),
347 providers: HashMap::new(),
348 surfaces: SurfaceConfig::default(),
349 skills: SkillsConfig::default(),
350 permissions: PermissionConfig::default(),
351 hooks: Vec::new(),
352 theme: "captain".into(),
353 config_dir: self.config_dir.clone(),
354 state_dir: default_state_dir(),
355 forced_model: None,
356 };
357 if let Ok(v) = std::env::var("OLLAMA_HOST") {
359 c.providers.insert(
360 "ollama".into(),
361 ProviderConfig {
362 adapter: "ollama".into(),
363 base_url: Some(v),
364 models: vec![],
365 api_key_env: None,
366 },
367 );
368 }
369 c
370 };
371 Self::apply_env_overrides(&mut cfg);
372 if cfg.theme.trim().is_empty() {
373 cfg.theme = default_theme();
374 }
375 Ok(cfg)
376 }
377
378 fn save(&self, c: &Config) -> anyhow::Result<()> {
379 let path = self.config_path();
380 if let Some(parent) = path.parent() {
381 std::fs::create_dir_all(parent)?;
382 }
383 let content = toml::to_string_pretty(c)?;
384 std::fs::write(&path, content)?;
385 Ok(())
386 }
387}
388
389pub fn effective_provider_configs(config: &Config) -> HashMap<String, ProviderConfig> {
392 use crate::auth::AuthStore; let mut effective = config.providers.clone();
394 let auth = crate::auth::store::ChainedAuthStore::new(config.config_dir.clone());
395
396 for (name, pconfig) in effective.iter_mut() {
397 if pconfig.models.is_empty() {
398 pconfig.models = providers::default_models(name);
399 }
400 }
401
402 for def in providers::provider_registry() {
403 if effective.contains_key(&def.id) {
404 continue;
405 }
406
407 let has_env_credential = def
408 .api_key_env
409 .as_ref()
410 .map(|env| {
411 if def.adapter == "ollama" {
412 true
413 } else {
414 std::env::var(env)
415 .map(|value| !value.trim().is_empty())
416 .unwrap_or(false)
417 }
418 })
419 .unwrap_or(def.adapter == "ollama");
420 let has_stored_credential = auth.get(&def.id).is_some();
421
422 if !has_env_credential && !has_stored_credential {
423 continue;
424 }
425
426 let base_url = if def.adapter == "ollama" {
427 std::env::var("OLLAMA_HOST")
428 .ok()
429 .or(Some(def.base_url.clone()))
430 } else {
431 Some(def.base_url.clone())
432 };
433
434 effective.insert(
435 def.id.clone(),
436 ProviderConfig {
437 adapter: def.adapter,
438 base_url,
439 models: providers::default_models(&def.id),
440 api_key_env: def.api_key_env,
441 },
442 );
443 }
444
445 effective
446}