Skip to main content

garudust_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use serde::{Deserialize, Serialize};
6
7use crate::types::ReasoningEffort;
8
9static DOTENV_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
10
11/// Load ~/.garudust/.env once per process into an in-memory map.
12/// Never writes to process environment, so secrets are not visible to subprocesses.
13fn load_dotenv_once(path: &Path) -> &'static HashMap<String, String> {
14    DOTENV_VARS.get_or_init(|| {
15        let mut map = HashMap::new();
16        let Ok(content) = std::fs::read_to_string(path) else {
17            return map;
18        };
19        for line in content.lines() {
20            let line = line.trim();
21            if line.is_empty() || line.starts_with('#') {
22                continue;
23            }
24            if let Some((k, v)) = line.split_once('=') {
25                let k = k.trim().to_string();
26                let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
27                map.insert(k, v);
28            }
29        }
30        map
31    })
32}
33
34/// Read an env var: real environment takes priority, dotenv map is fallback.
35fn env_or_dotenv(key: &str, dotenv: &HashMap<String, String>) -> Option<String> {
36    std::env::var(key)
37        .ok()
38        .filter(|v| !v.is_empty())
39        .or_else(|| dotenv.get(key).filter(|v| !v.is_empty()).cloned())
40}
41
42/// Read a secret from real env or ~/.garudust/.env (whichever is set first).
43/// Useful for Rust tools that don't go through script.rs env forwarding.
44pub fn get_secret(key: &str) -> Option<String> {
45    std::env::var(key)
46        .ok()
47        .filter(|v| !v.is_empty())
48        .or_else(|| {
49            DOTENV_VARS
50                .get()?
51                .get(key)
52                .filter(|v| !v.is_empty())
53                .cloned()
54        })
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct AgentConfig {
59    #[serde(skip)]
60    pub home_dir: PathBuf,
61    #[serde(default = "default_model")]
62    pub model: String,
63    #[serde(default = "default_max_iterations")]
64    pub max_iterations: u32,
65    /// Maximum iterations for sub-agents spawned via delegate_task / delegate_tasks.
66    /// Defaults to `max_iterations` when unset, letting you cap sub-agents lower than
67    /// the parent (e.g. `sub_agent_max_iterations: 10`) to limit runaway delegation chains.
68    #[serde(default)]
69    pub sub_agent_max_iterations: Option<u32>,
70    #[serde(default)]
71    pub tool_delay_ms: u64,
72    #[serde(default = "default_provider")]
73    pub provider: String,
74    pub base_url: Option<String>,
75    #[serde(skip)]
76    pub api_key: Option<String>,
77    /// Fallback API keys tried in order when the primary key returns 401/403.
78    /// Set via `LLM_FALLBACK_API_KEYS` env var or .env file (comma-separated values).
79    #[serde(skip)]
80    pub fallback_api_keys: Vec<String>,
81    #[serde(default)]
82    pub compression: CompressionConfig,
83    #[serde(default)]
84    pub network: NetworkConfig,
85    #[serde(default)]
86    pub mcp_servers: Vec<McpServerConfig>,
87    #[serde(default)]
88    pub max_concurrent_requests: Option<usize>,
89    #[serde(default)]
90    pub security: SecurityConfig,
91    #[serde(default)]
92    pub memory_expiry: MemoryExpiryConfig,
93    /// Inject a memory-save reminder every N tool-use iterations within a task.
94    /// 0 = disabled. Default: 5.
95    #[serde(default = "default_nudge_interval")]
96    pub nudge_interval: u32,
97    /// Max retry attempts on transient LLM API errors (429, 5xx, network). 0 = disabled.
98    #[serde(default = "default_llm_max_retries")]
99    pub llm_max_retries: u32,
100    /// Base delay in milliseconds for exponential backoff between retries.
101    #[serde(default = "default_llm_retry_base_ms")]
102    pub llm_retry_base_ms: u64,
103    /// Platform-level access controls (whitelist, mention gate, session isolation).
104    #[serde(default)]
105    pub platform: PlatformConfig,
106    /// Minimum tool-use iterations that trigger an automatic skill-reflection pass after a task.
107    /// The agent reviews the conversation and calls write_skill if the workflow is reusable.
108    /// Set to 0 to disable. Default: 5.
109    #[serde(default = "default_auto_skill_threshold")]
110    pub auto_skill_threshold: u32,
111    /// Timeout in seconds for a single LLM API call (chat or stream). 0 = no timeout. Default: 120.
112    #[serde(default = "default_llm_timeout_secs")]
113    pub llm_timeout_secs: u64,
114    /// Timeout in seconds applied to every non-terminal tool dispatch. 0 = no timeout. Default: 60.
115    #[serde(default = "default_tool_timeout_secs")]
116    pub tool_timeout_secs: u64,
117    /// Drain window in seconds for graceful shutdown — server waits this long for in-flight
118    /// requests to complete before forcing exit. Default: 30.
119    #[serde(default = "default_shutdown_timeout_secs")]
120    pub shutdown_timeout_secs: u64,
121    /// Hard cap on total tokens (input + output) consumed by a single task.
122    /// When exceeded the agent stops and returns what it has with a budget notice.
123    /// `None` means no limit.
124    #[serde(default)]
125    pub max_tokens_per_task: Option<u32>,
126    /// Maximum output tokens per LLM request. Default: 8192.
127    /// Lower this for models with small context windows (e.g. 4096 for a 27k-ctx model).
128    #[serde(default)]
129    pub max_output_tokens: Option<u32>,
130    /// Reasoning effort for supported models (Claude extended thinking, OpenAI o1/o3/o4).
131    /// Set via config.yaml: `reasoning_effort: medium`
132    #[serde(default)]
133    pub reasoning_effort: Option<ReasoningEffort>,
134    /// Maximum context window of the model in tokens.
135    /// Used by the context compressor to decide when to summarise history.
136    /// Defaults to 128 000. Set this to the actual limit for small-context models
137    /// (e.g. `context_window: 27168` for Qwen3-14B-AWQ on vLLM).
138    #[serde(default)]
139    pub context_window: Option<usize>,
140    /// Toolsets to disable. Removes all tools in the named toolset from every
141    /// request, reducing context usage for small-context models.
142    /// Available toolsets: web, files, terminal, memory, skills, agent,
143    ///   browser, git, notes, json, mcp
144    /// Providers: anthropic, openrouter, vllm, ollama, bedrock, codex, thaillm
145    /// Example: `disabled_toolsets: [browser, git, notes, json, agent]`
146    #[serde(default)]
147    pub disabled_toolsets: Vec<String>,
148    /// Individual tools to disable by exact name. Useful when only some tools
149    /// in a toolset need to be removed (e.g. disable `image_read` without
150    /// removing the entire `files` toolset).
151    /// Example: `disabled_tools: [image_read, pdf_read, session_search]`
152    #[serde(default)]
153    pub disabled_tools: Vec<String>,
154    /// Append a usage footer (`[N iter | Xin Yout Ztok @ model]`) to every
155    /// agent response. Useful for debugging; usually unwanted on chat platforms
156    /// where end users see the output. Default: false.
157    #[serde(default)]
158    pub show_usage_footer: bool,
159    /// Per-platform webhook server settings (LINE, WhatsApp, generic webhook).
160    /// Each entry sets enabled flag, listening port, and HTTP path. Tokens and
161    /// secrets continue to be read from `~/.garudust/.env` — never from yaml.
162    #[serde(default)]
163    pub platforms: WebhookPlatformsConfig,
164    /// HTTP gateway server settings (port, …). Overridden by `--port` and
165    /// `GARUDUST_PORT` env var.
166    #[serde(default)]
167    pub server: ServerConfig,
168    /// Cron scheduler — recurring agent tasks plus the memory consolidation /
169    /// expiry sweeps. CLI flags (`--cron-jobs`, `--memory-cron`,
170    /// `--memory-expiry-cron`) and the corresponding env vars take precedence.
171    #[serde(default)]
172    pub cron: CronConfig,
173}
174
175fn default_model() -> String {
176    "anthropic/claude-sonnet-4-6".into()
177}
178fn default_provider() -> String {
179    "openrouter".into()
180}
181fn default_max_iterations() -> u32 {
182    90
183}
184fn default_nudge_interval() -> u32 {
185    5
186}
187fn default_auto_skill_threshold() -> u32 {
188    5
189}
190fn default_llm_max_retries() -> u32 {
191    3
192}
193fn default_llm_retry_base_ms() -> u64 {
194    1000
195}
196fn default_llm_timeout_secs() -> u64 {
197    120
198}
199fn default_tool_timeout_secs() -> u64 {
200    60
201}
202fn default_shutdown_timeout_secs() -> u64 {
203    30
204}
205
206/// Per-category retention policy for memory entries.
207/// `None` means the category never expires.
208/// `preference` and `skill` default to `None` — they represent durable knowledge.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct MemoryExpiryConfig {
211    /// Max age in days for `fact` entries. Default: 90.
212    #[serde(default = "default_fact_days")]
213    pub fact_days: Option<u32>,
214    /// Max age in days for `project` entries. Default: 30.
215    #[serde(default = "default_project_days")]
216    pub project_days: Option<u32>,
217    /// Max age in days for `other` entries. Default: 60.
218    #[serde(default = "default_other_days")]
219    pub other_days: Option<u32>,
220    /// `preference` entries never expire by default.
221    #[serde(default)]
222    pub preference_days: Option<u32>,
223    /// `skill` entries never expire by default.
224    #[serde(default)]
225    pub skill_days: Option<u32>,
226}
227
228#[allow(clippy::unnecessary_wraps)]
229fn default_fact_days() -> Option<u32> {
230    Some(90)
231}
232#[allow(clippy::unnecessary_wraps)]
233fn default_project_days() -> Option<u32> {
234    Some(30)
235}
236#[allow(clippy::unnecessary_wraps)]
237fn default_other_days() -> Option<u32> {
238    Some(60)
239}
240
241impl Default for MemoryExpiryConfig {
242    fn default() -> Self {
243        Self {
244            fact_days: default_fact_days(),
245            project_days: default_project_days(),
246            other_days: default_other_days(),
247            preference_days: None,
248            skill_days: None,
249        }
250    }
251}
252
253/// Terminal execution sandbox mode.
254#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
255#[serde(rename_all = "lowercase")]
256pub enum TerminalSandbox {
257    /// Direct host execution (default). Hardline blocks still apply.
258    #[default]
259    None,
260    /// Wrap every command in `docker run --rm` with hardened flags.
261    Docker,
262}
263
264/// Security-related settings grouped together (mirrors CompressionConfig / NetworkConfig pattern).
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct SecurityConfig {
267    /// Bearer token required on /chat* endpoints. None = open (warn at startup).
268    #[serde(skip)]
269    pub gateway_api_key: Option<String>,
270
271    /// Allowed root paths for read_file tool. Defaults to cwd + home.
272    #[serde(default)]
273    pub allowed_read_paths: Vec<PathBuf>,
274
275    /// Allowed root paths for write_file tool. Defaults to cwd only.
276    #[serde(default)]
277    pub allowed_write_paths: Vec<PathBuf>,
278
279    /// Command approval mode: "auto" | "smart" | "deny". Default "smart".
280    #[serde(default = "default_approval_mode")]
281    pub approval_mode: String,
282
283    /// Per-IP rate limit in requests/minute. None = disabled.
284    #[serde(default)]
285    pub rate_limit_rpm: Option<u32>,
286
287    /// Terminal execution sandbox. Default "none" (direct host execution).
288    #[serde(default)]
289    pub terminal_sandbox: TerminalSandbox,
290
291    /// Docker image used when `terminal_sandbox = docker`. Default "ubuntu:24.04".
292    #[serde(default = "default_sandbox_image")]
293    pub terminal_sandbox_image: String,
294
295    /// Extra `docker run` flags appended after the hardened defaults.
296    /// Example: `["--network=none", "--memory=512m", "--cpus=0.5"]`
297    #[serde(default)]
298    pub terminal_sandbox_opts: Vec<String>,
299}
300
301fn default_approval_mode() -> String {
302    "smart".to_string()
303}
304
305fn default_sandbox_image() -> String {
306    "ubuntu:24.04".to_string()
307}
308
309impl Default for SecurityConfig {
310    fn default() -> Self {
311        Self {
312            gateway_api_key: None,
313            allowed_read_paths: Vec::new(),
314            allowed_write_paths: Vec::new(),
315            approval_mode: default_approval_mode(),
316            rate_limit_rpm: None,
317            terminal_sandbox: TerminalSandbox::None,
318            terminal_sandbox_image: default_sandbox_image(),
319            terminal_sandbox_opts: Vec::new(),
320        }
321    }
322}
323
324/// Platform-level access and behaviour controls (whitelist, mention gate, session isolation).
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct PlatformConfig {
327    /// User IDs allowed to send messages to the agent.
328    /// Empty list means everyone is allowed.
329    #[serde(default)]
330    pub allowed_user_ids: Vec<String>,
331
332    /// Only respond in group chats when the bot is @mentioned.
333    /// Private / DM chats always get a response regardless of this flag.
334    #[serde(default)]
335    pub require_mention: bool,
336
337    /// Bot username used for @mention detection (without the @).
338    /// Example: set to "mybot" so @mybot triggers a response.
339    #[serde(default)]
340    pub bot_username: String,
341
342    /// Give each user their own conversation session (default: true).
343    /// Set to false only when you want all users in a channel to share one session.
344    /// Not applied to the webhook platform — webhook callers control session routing via payload.
345    #[serde(default = "default_true")]
346    pub session_per_user: bool,
347}
348
349fn default_true() -> bool {
350    true
351}
352
353impl Default for PlatformConfig {
354    fn default() -> Self {
355        Self {
356            allowed_user_ids: Vec::new(),
357            require_mention: false,
358            bot_username: String::new(),
359            session_per_user: true,
360        }
361    }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct McpServerConfig {
366    pub name: String,
367    pub command: String,
368    #[serde(default)]
369    pub args: Vec<String>,
370}
371
372/// Per-platform webhook server settings. A `WebhookPlatformConfig` with
373/// `enabled = false` means the adapter is not started even if its secret is
374/// present in the environment.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct WebhookPlatformConfig {
377    /// Whether to start this adapter at boot.
378    #[serde(default)]
379    pub enabled: bool,
380    /// TCP port to bind on `0.0.0.0`.
381    pub port: u16,
382    /// HTTP path the adapter listens on (e.g. `/webhooks/line`).
383    pub webhook_path: String,
384}
385
386/// Container for all webhook-based platform settings.
387#[derive(Debug, Clone, Serialize, Deserialize, Default)]
388pub struct WebhookPlatformsConfig {
389    #[serde(default)]
390    pub line: Option<WebhookPlatformConfig>,
391    #[serde(default)]
392    pub whatsapp: Option<WebhookPlatformConfig>,
393    #[serde(default)]
394    pub webhook: Option<WebhookPlatformConfig>,
395}
396
397/// HTTP gateway server settings.
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct ServerConfig {
400    /// TCP port for the HTTP gateway. Default `3000`.
401    #[serde(default = "default_server_port")]
402    pub port: u16,
403}
404
405fn default_server_port() -> u16 {
406    3000
407}
408
409/// Parse a comma-separated `"cron_expr=task"` env var into structured [`CronJob`]s.
410/// Mirrors `garudust_cron::parse_job_pairs` (kept inline to avoid a core→cron dep cycle).
411fn parse_cron_jobs_str(s: &str) -> Vec<CronJob> {
412    s.split(',')
413        .filter_map(|entry| {
414            let (expr, task) = entry.trim().split_once('=')?;
415            Some(CronJob {
416                schedule: expr.trim().to_string(),
417                task: task.trim().to_string(),
418            })
419        })
420        .collect()
421}
422
423impl Default for ServerConfig {
424    fn default() -> Self {
425        Self {
426            port: default_server_port(),
427        }
428    }
429}
430
431/// A single scheduled agent task — cron expression plus the prompt to run.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct CronJob {
434    /// Standard 5-field cron expression (e.g. `0 9 * * *`).
435    pub schedule: String,
436    /// The task prompt handed to the agent when the cron fires.
437    pub task: String,
438}
439
440/// Cron scheduler configuration.
441#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442pub struct CronConfig {
443    /// Recurring agent tasks.
444    #[serde(default)]
445    pub jobs: Vec<CronJob>,
446    /// Cron expression for automatic memory consolidation. `None` = disabled.
447    #[serde(default)]
448    pub memory_consolidation: Option<String>,
449    /// Cron expression for automatic memory expiry sweeps. `None` = disabled.
450    #[serde(default)]
451    pub memory_expiry: Option<String>,
452}
453
454impl WebhookPlatformConfig {
455    /// Defaults for the generic webhook adapter. Used when no explicit
456    /// `platforms.webhook` block is present so existing setups keep working.
457    pub fn default_webhook() -> Self {
458        Self {
459            enabled: true,
460            port: 3001,
461            webhook_path: "/webhook".to_string(),
462        }
463    }
464
465    /// Defaults for LINE. Constructed by the setup wizard when the user opts
466    /// in, so `enabled = true`; for manual yaml authors, `enabled` itself
467    /// defaults to `false` via serde, keeping the adapter opt-in.
468    pub fn default_line() -> Self {
469        Self {
470            enabled: true,
471            port: 3002,
472            webhook_path: "/line".to_string(),
473        }
474    }
475
476    /// Defaults for WhatsApp — same semantics as `default_line`.
477    pub fn default_whatsapp() -> Self {
478        Self {
479            enabled: true,
480            port: 3003,
481            webhook_path: "/whatsapp".to_string(),
482        }
483    }
484}
485
486impl Default for AgentConfig {
487    fn default() -> Self {
488        let cwd = std::env::current_dir().unwrap_or_default();
489        let home = dirs::home_dir().unwrap_or_default();
490        Self {
491            home_dir: Self::garudust_dir(),
492            model: "anthropic/claude-sonnet-4-6".into(),
493            max_iterations: 90,
494            sub_agent_max_iterations: None,
495            tool_delay_ms: 0,
496            provider: "openrouter".into(),
497            base_url: None,
498            api_key: None,
499            fallback_api_keys: Vec::new(),
500            compression: CompressionConfig::default(),
501            network: NetworkConfig::default(),
502            mcp_servers: Vec::new(),
503            max_concurrent_requests: None,
504            security: SecurityConfig {
505                gateway_api_key: None,
506                allowed_read_paths: vec![cwd.clone(), home],
507                allowed_write_paths: vec![cwd],
508                approval_mode: default_approval_mode(),
509                rate_limit_rpm: None,
510                terminal_sandbox: TerminalSandbox::None,
511                terminal_sandbox_image: default_sandbox_image(),
512                terminal_sandbox_opts: Vec::new(),
513            },
514            memory_expiry: MemoryExpiryConfig::default(),
515            nudge_interval: default_nudge_interval(),
516            llm_max_retries: default_llm_max_retries(),
517            llm_retry_base_ms: default_llm_retry_base_ms(),
518            platform: PlatformConfig::default(),
519            auto_skill_threshold: default_auto_skill_threshold(),
520            llm_timeout_secs: default_llm_timeout_secs(),
521            tool_timeout_secs: default_tool_timeout_secs(),
522            shutdown_timeout_secs: default_shutdown_timeout_secs(),
523            max_tokens_per_task: None,
524            max_output_tokens: None,
525            reasoning_effort: None,
526            context_window: None,
527            disabled_toolsets: Vec::new(),
528            disabled_tools: Vec::new(),
529            show_usage_footer: false,
530            platforms: WebhookPlatformsConfig {
531                webhook: Some(WebhookPlatformConfig::default_webhook()),
532                line: None,
533                whatsapp: None,
534            },
535            server: ServerConfig::default(),
536            cron: CronConfig::default(),
537        }
538    }
539}
540
541impl AgentConfig {
542    /// Canonical ~/.garudust directory.
543    pub fn garudust_dir() -> PathBuf {
544        dirs::home_dir()
545            .unwrap_or_else(|| PathBuf::from("/tmp"))
546            .join(".garudust")
547    }
548
549    /// Load config from ~/.garudust/config.yaml + ~/.garudust/.env + environment.
550    ///
551    /// Priority (highest first):
552    ///   1. Environment variables already set in the shell
553    ///   2. ~/.garudust/.env  (set if not already present in env)
554    ///   3. ~/.garudust/config.yaml
555    ///   4. Built-in defaults
556    pub fn load() -> Self {
557        let home_dir = Self::garudust_dir();
558
559        // Load dotenv values into memory (never calls set_var — secrets stay out of process env)
560        let env_file = home_dir.join(".env");
561        let dotenv = load_dotenv_once(&env_file);
562
563        // Load config.yaml (non-secret settings)
564        let yaml_path = home_dir.join("config.yaml");
565        let mut config: AgentConfig = if yaml_path.exists() {
566            let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
567            serde_yaml::from_str(&src).unwrap_or_default()
568        } else {
569            AgentConfig::default()
570        };
571
572        config.home_dir = home_dir;
573
574        // Populate default security paths if they came back empty from YAML
575        if config.security.allowed_read_paths.is_empty() {
576            let cwd = std::env::current_dir().unwrap_or_default();
577            let home = dirs::home_dir().unwrap_or_default();
578            config.security.allowed_read_paths = vec![cwd.clone(), home];
579            config.security.allowed_write_paths = vec![cwd];
580        }
581
582        // Provider→env binding rule:
583        //   1. If config.yaml explicitly set the provider, load *only* that provider's
584        //      env key. Other providers' keys (e.g. OPENROUTER_API_KEY left in .env for
585        //      tools like view_image) must not leak into config.api_key.
586        //   2. If yaml did not exist (config.provider is still default), allow env-based
587        //      auto-detection: ANTHROPIC_API_KEY → anthropic, OPENROUTER_API_KEY → openrouter,
588        //      VLLM_BASE_URL → vllm, etc.
589        //
590        // This prevents the "tool credential leaks into LLM transport" bug while
591        // preserving zero-config UX for users who only set one *_API_KEY in env.
592        let yaml_authoritative = yaml_path.exists();
593
594        if yaml_authoritative {
595            if config.api_key.is_none() {
596                config.api_key = match config.provider.as_str() {
597                    "vllm" => env_or_dotenv("VLLM_API_KEY", dotenv),
598                    "anthropic" => env_or_dotenv("ANTHROPIC_API_KEY", dotenv),
599                    "thaillm" => env_or_dotenv("THAILLM_API_KEY", dotenv),
600                    "ollama" | "bedrock" | "codex" => None,
601                    // openrouter and any unknown provider default to OpenRouter's key
602                    _ => env_or_dotenv("OPENROUTER_API_KEY", dotenv),
603                };
604            }
605        } else {
606            // Auto-detect provider from env when no yaml is present.
607            if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
608                config.api_key = Some(k);
609                config.provider = "anthropic".into();
610            } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
611                config.provider = "ollama".into();
612                config.base_url = Some(url);
613            } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
614                config.provider = "vllm".into();
615                config.base_url = Some(url);
616                config.api_key = env_or_dotenv("VLLM_API_KEY", dotenv);
617            } else if let Some(k) = env_or_dotenv("THAILLM_API_KEY", dotenv) {
618                config.api_key = Some(k);
619                config.provider = "thaillm".into();
620            } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
621                config.api_key = Some(k);
622                config.provider = "openrouter".into();
623            }
624        }
625        if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
626            config.model = m;
627        }
628        if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
629            config.base_url = Some(u);
630        }
631        if let Some(v) = env_or_dotenv("LLM_FALLBACK_API_KEYS", dotenv) {
632            config.fallback_api_keys = v
633                .split(',')
634                .map(str::trim)
635                .filter(|s| !s.is_empty())
636                .map(str::to_string)
637                .collect();
638        }
639        if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
640            config.security.gateway_api_key = Some(k);
641        }
642        if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
643            if let Ok(n) = v.parse::<u32>() {
644                config.security.rate_limit_rpm = Some(n);
645            }
646        }
647        if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
648            config.security.approval_mode = mode;
649        }
650        if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
651            config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
652                "docker" => TerminalSandbox::Docker,
653                _ => TerminalSandbox::None,
654            };
655        }
656        if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
657            config.security.terminal_sandbox_image = image;
658        }
659
660        // Non-secret env vars that previously reached clap via `dotenvy::from_path`.
661        // Reading them here lets us drop dotenvy from main.rs without losing the
662        // ability for operators to set these in ~/.garudust/.env. CLI flags still
663        // override these because main.rs applies CLI > config precedence at use sites.
664        if let Some(v) = env_or_dotenv("GARUDUST_PORT", dotenv) {
665            if let Ok(n) = v.parse::<u16>() {
666                config.server.port = n;
667            }
668        }
669        if let Some(v) = env_or_dotenv("GARUDUST_MEMORY_CRON", dotenv) {
670            config.cron.memory_consolidation = Some(v);
671        }
672        if let Some(v) = env_or_dotenv("GARUDUST_MEMORY_EXPIRY_CRON", dotenv) {
673            config.cron.memory_expiry = Some(v);
674        }
675        if let Some(v) = env_or_dotenv("GARUDUST_CRON_JOBS", dotenv) {
676            config.cron.jobs = parse_cron_jobs_str(&v);
677        }
678
679        config
680    }
681
682    /// Save non-secret settings to ~/.garudust/config.yaml.
683    pub fn save_yaml(&self) -> std::io::Result<()> {
684        std::fs::create_dir_all(&self.home_dir)?;
685        let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
686        std::fs::write(self.home_dir.join("config.yaml"), yaml)
687    }
688
689    /// Write or update a KEY=VALUE line in ~/.garudust/.env.
690    pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
691        std::fs::create_dir_all(home_dir)?;
692        let env_path = home_dir.join(".env");
693        let existing = if env_path.exists() {
694            std::fs::read_to_string(&env_path)?
695        } else {
696            String::new()
697        };
698
699        let prefix = format!("{key}=");
700        let mut lines: Vec<String> = existing
701            .lines()
702            .filter(|l| !l.starts_with(&prefix))
703            .map(String::from)
704            .collect();
705        lines.push(format!("{key}={value}"));
706
707        std::fs::write(&env_path, lines.join("\n") + "\n")
708    }
709}
710
711// ── Sub-configs ──────────────────────────────────────────────────────────────
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct CompressionConfig {
715    pub enabled: bool,
716    pub threshold_fraction: f32,
717    pub model: Option<String>,
718}
719
720impl Default for CompressionConfig {
721    fn default() -> Self {
722        Self {
723            enabled: true,
724            threshold_fraction: 0.8,
725            model: None,
726        }
727    }
728}
729
730#[derive(Debug, Clone, Serialize, Deserialize, Default)]
731pub struct NetworkConfig {
732    pub force_ipv4: bool,
733    pub proxy: Option<String>,
734}