Skip to main content

synaps_cli/core/
config.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4use crate::tools::shell::config::ShellConfig;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8
9/// Provider API keys parsed from `provider.<name> = ...` lines in config.
10/// Empty if `load_config()` hasn't been called. The registry falls back to
11/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
12pub fn get_provider_keys() -> BTreeMap<String, String> {
13    PROVIDER_KEYS.get().cloned().unwrap_or_default()
14}
15
16/// Returns the active profile name, if any.
17/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
18pub fn get_profile() -> Option<String> {
19    PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
20}
21
22/// Sets the active profile name. Must be called before any `get_profile()` call
23/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
24/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
25pub fn set_profile(name: Option<String>) {
26    let _ = PROFILE_NAME.set(name);
27}
28
29pub fn base_dir() -> PathBuf {
30    if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
31        return PathBuf::from(path);
32    }
33    let home = std::env::var("HOME")
34        .or_else(|_| std::env::var("USERPROFILE"))
35        .unwrap_or_else(|_| ".".to_string());
36    PathBuf::from(home).join(".synaps-cli")
37}
38
39/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
40#[doc(hidden)]
41pub fn set_base_dir_for_tests(path: PathBuf) {
42    std::env::set_var("SYNAPS_BASE_DIR", path);
43}
44
45/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
46pub fn resolve_read_path(filename: &str) -> PathBuf {
47    let base = base_dir();
48    
49    if let Some(profile) = get_profile() {
50        let profile_path = base.join(&profile).join(filename);
51        if profile_path.exists() {
52            return profile_path;
53        }
54    }
55    
56    base.join(filename)
57}
58
59/// Resolves a path for reading with an extended arbitrary path tree.
60pub fn resolve_read_path_extended(path: &str) -> PathBuf {
61    let base = base_dir();
62    
63    if let Some(profile) = get_profile() {
64        let profile_path = base.join(&profile).join(path);
65        if profile_path.exists() {
66            return profile_path;
67        }
68    }
69    
70    base.join(path)
71}
72
73/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
74pub fn resolve_write_path(filename: &str) -> PathBuf {
75    let mut base = base_dir();
76    
77    if let Some(profile) = get_profile() {
78        base.push(profile);
79    }
80    
81    let _ = std::fs::create_dir_all(&base);
82    base.join(filename)
83}
84
85/// Gets the absolute directory for the current profile (or root if default).
86pub fn get_active_config_dir() -> PathBuf {
87    let mut base = base_dir();
88    if let Some(profile) = get_profile() {
89        base.push(profile);
90    }
91    base
92}
93
94/// Parsed configuration from the config file.
95#[derive(Debug, Clone)]
96pub struct SynapsConfig {
97    pub model: Option<String>,
98    pub thinking_budget: Option<u32>,
99    pub context_window: Option<u64>,   // override auto-detected context window (tokens)
100    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
101    pub max_tool_output: usize,        // default 30000
102    pub bash_timeout: u64,             // default 30
103    pub bash_max_timeout: u64,         // default 300
104    pub subagent_timeout: u64,         // default 300
105    pub api_retries: u32,              // default 3
106    pub theme: Option<String>,
107    pub agent_name: Option<String>,
108    pub disabled_plugins: Vec<String>,
109    pub favorite_models: Vec<String>,
110    pub disabled_skills: Vec<String>,
111    pub shell: ShellConfig,
112    pub provider_keys: BTreeMap<String, String>,
113    pub keybinds: std::collections::HashMap<String, String>,
114}
115
116impl Default for SynapsConfig {
117    fn default() -> Self {
118        Self {
119            model: None,
120            thinking_budget: None,
121            context_window: None,
122            compaction_model: None,
123            max_tool_output: 30000,
124            bash_timeout: 30,
125            bash_max_timeout: 300,
126            subagent_timeout: 300,
127            api_retries: 3,
128            theme: None,
129            agent_name: None,
130            disabled_plugins: Vec::new(),
131            favorite_models: Vec::new(),
132            disabled_skills: Vec::new(),
133            shell: ShellConfig::default(),
134            provider_keys: BTreeMap::new(),
135            keybinds: std::collections::HashMap::new(),
136        }
137    }
138}
139
140
141fn parse_thinking_budget(val: &str) -> Option<u32> {
142    match val {
143        "low" => Some(2048),
144        "medium" => Some(4096),
145        "high" => Some(16384),
146        "xhigh" => Some(32768),
147        "adaptive" => Some(0), // sentinel: model decides depth
148        _ => val.parse::<u32>().ok(),
149    }
150}
151
152fn parse_comma_list(val: &str) -> Vec<String> {
153    val.split(',')
154        .map(|s| s.trim().to_string())
155        .filter(|s| !s.is_empty())
156        .collect()
157}
158
159fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
160    write_config_value(key, &values.join(", "))
161}
162
163/// Parse shell.* configuration keys and update the ShellConfig.
164fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
165    match key {
166        "shell.max_sessions" => {
167            if let Ok(sessions) = val.parse::<usize>() {
168                shell_config.max_sessions = sessions;
169            } else {
170                eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
171            }
172        }
173        "shell.idle_timeout" => {
174            if let Ok(timeout) = val.parse::<u64>() {
175                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
176            } else {
177                eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
178            }
179        }
180        "shell.readiness_timeout_ms" => {
181            if let Ok(timeout) = val.parse::<u64>() {
182                shell_config.readiness_timeout_ms = timeout;
183            } else {
184                eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
185            }
186        }
187        "shell.max_readiness_timeout_ms" => {
188            if let Ok(timeout) = val.parse::<u64>() {
189                shell_config.max_readiness_timeout_ms = timeout;
190            } else {
191                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
192            }
193        }
194        "shell.default_rows" => {
195            if let Ok(rows) = val.parse::<u16>() {
196                shell_config.default_rows = rows;
197            } else {
198                eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
199            }
200        }
201        "shell.default_cols" => {
202            if let Ok(cols) = val.parse::<u16>() {
203                shell_config.default_cols = cols;
204            } else {
205                eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
206            }
207        }
208        "shell.readiness_strategy" => {
209            let val_lower = val.to_lowercase();
210            match val_lower.as_str() {
211                "timeout" | "prompt" | "hybrid" => {
212                    shell_config.readiness_strategy = val.to_string();
213                }
214                _ => {
215                    eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
216                }
217            }
218        }
219        "shell.max_output" => {
220            if let Ok(max_output) = val.parse::<usize>() {
221                shell_config.max_output = max_output;
222            } else {
223                eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
224            }
225        }
226        _ => {
227            // Unknown shell.* keys are preserved (not rejected)
228        }
229    }
230}
231
232/// Parse the config file at ~/.synaps-cli/config (or profile variant).
233/// Returns default config if file doesn't exist or can't be read.
234pub fn load_config() -> SynapsConfig {
235    let path = resolve_read_path("config");
236    let mut config = SynapsConfig::default();
237    
238    let Ok(content) = std::fs::read_to_string(&path) else {
239        return config;
240    };
241    
242    for line in content.lines() {
243        let line = line.trim();
244        if line.is_empty() || line.starts_with('#') { continue; }
245        let Some((key, val)) = line.split_once('=') else { continue };
246        let key = key.trim();
247        let val = val.trim();
248        match key {
249            "model" => config.model = Some(val.to_string()),
250            "thinking" => config.thinking_budget = parse_thinking_budget(val),
251            "compaction_model" => config.compaction_model = Some(val.to_string()),
252            "context_window" => {
253                let parsed = match val {
254                    "200k" | "200K" => Some(200_000),
255                    "1m" | "1M" => Some(1_000_000),
256                    _ => val.parse::<u64>().ok(),
257                };
258                config.context_window = parsed;
259            }
260            "max_tool_output" => {
261                if let Ok(size) = val.parse::<usize>() {
262                    config.max_tool_output = size;
263                }
264            }
265            "bash_timeout" => {
266                if let Ok(timeout) = val.parse::<u64>() {
267                    config.bash_timeout = timeout;
268                }
269            }
270            "bash_max_timeout" => {
271                if let Ok(timeout) = val.parse::<u64>() {
272                    config.bash_max_timeout = timeout;
273                }
274            }
275            "subagent_timeout" => {
276                if let Ok(timeout) = val.parse::<u64>() {
277                    config.subagent_timeout = timeout;
278                }
279            }
280            "api_retries" => {
281                if let Ok(retries) = val.parse::<u32>() {
282                    config.api_retries = retries;
283                }
284            }
285            "theme" => config.theme = Some(val.to_string()),
286            "agent_name" => config.agent_name = Some(val.to_string()),
287            "disabled_plugins" => {
288                config.disabled_plugins = parse_comma_list(val);
289            }
290            "favorite_models" => {
291                config.favorite_models = parse_comma_list(val);
292            }
293            "disabled_skills" => {
294                config.disabled_skills = parse_comma_list(val);
295            }
296            _ => {
297                // Handle shell.* keys
298                if key.starts_with("shell.") {
299                    parse_shell_config_key(&mut config.shell, key, val);
300                } else if let Some(provider_key) = key.strip_prefix("provider.") {
301                    config.provider_keys.insert(provider_key.to_string(), val.to_string());
302                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
303                    config.keybinds.insert(keybind_key.to_string(), val.to_string());
304                }
305                // Other unknown keys silently ignored
306            }
307        }
308    }
309
310    // Publish provider keys to the process-wide cache for the API router.
311    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
312    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
313
314    config
315}
316
317/// Read a single config value by exact key from the active config file.
318pub fn read_config_value(key: &str) -> Option<String> {
319    let path = resolve_read_path("config");
320    let content = std::fs::read_to_string(&path).ok()?;
321    for line in content.lines() {
322        let line = line.trim();
323        if line.is_empty() || line.starts_with('#') { continue; }
324        let Some((k, v)) = line.split_once('=') else { continue };
325        if k.trim() == key.trim() {
326            return Some(v.trim().to_string());
327        }
328    }
329    None
330}
331
332/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
333/// Replaces the first existing line that matches the key, or appends if absent.
334/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
335pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
336    let path = resolve_write_path("config");
337    let existing = std::fs::read_to_string(&path).unwrap_or_default();
338
339    let key_trimmed = key.trim();
340    let replacement = format!("{} = {}", key_trimmed, value);
341
342    let mut found = false;
343    let mut new_lines: Vec<String> = existing.lines().map(|line| {
344        if found { return line.to_string(); }
345        let t = line.trim_start();
346        if t.starts_with('#') || t.is_empty() { return line.to_string(); }
347        if let Some((k, _)) = t.split_once('=') {
348            if k.trim() == key_trimmed {
349                found = true;
350                return replacement.clone();
351            }
352        }
353        line.to_string()
354    }).collect();
355
356    if !found {
357        new_lines.push(replacement);
358    }
359
360    let mut out = new_lines.join("\n");
361    if !out.ends_with('\n') { out.push('\n'); }
362
363    let tmp = path.with_extension("tmp");
364    std::fs::write(&tmp, out)?;
365    // Config may contain API keys — restrict to owner-only
366    #[cfg(unix)]
367    {
368        use std::os::unix::fs::PermissionsExt;
369        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
370    }
371    std::fs::rename(&tmp, &path)?;
372    Ok(())
373}
374
375/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
376pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
377    let trimmed = id.trim();
378    if trimmed.is_empty() {
379        return Ok(());
380    }
381    let mut values = load_config().favorite_models;
382    if !values.iter().any(|v| v == trimmed) {
383        values.push(trimmed.to_string());
384        values.sort();
385    }
386    write_comma_list("favorite_models", &values)
387}
388
389/// Remove a favorite model id (`provider/model`) from config.
390pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
391    let mut values = load_config().favorite_models;
392    values.retain(|v| v != id.trim());
393    write_comma_list("favorite_models", &values)
394}
395
396/// Return whether a model id is marked as favorite.
397pub fn is_favorite_model(id: &str) -> bool {
398    load_config().favorite_models.iter().any(|v| v == id.trim())
399}
400
401/// Resolve the system prompt from CLI flag, config file, or default.
402/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
403pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
404    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
405        You have access to bash, read, and write tools. \
406        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
407
408    if let Some(val) = explicit {
409        let path = std::path::Path::new(val);
410        if path.exists() && path.is_file() {
411            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
412        }
413        return val.to_string();
414    }
415
416    let system_path = resolve_read_path("system.md");
417    if system_path.exists() {
418        return std::fs::read_to_string(&system_path).unwrap_or_default();
419    }
420
421    DEFAULT_PROMPT.to_string()
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use serial_test::serial;
428
429    #[test]
430    fn test_parse_thinking_budget() {
431        assert_eq!(parse_thinking_budget("low"), Some(2048));
432        assert_eq!(parse_thinking_budget("medium"), Some(4096));
433        assert_eq!(parse_thinking_budget("high"), Some(16384));
434        assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
435        assert_eq!(parse_thinking_budget("8192"), Some(8192));
436        assert_eq!(parse_thinking_budget("invalid"), None);
437    }
438
439    #[test]
440    fn test_base_dir() {
441        let path = base_dir();
442        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
443    }
444
445    #[test]
446    fn test_resolve_system_prompt_explicit() {
447        let result = resolve_system_prompt(Some("test prompt"));
448        assert_eq!(result, "test prompt");
449    }
450
451    #[test]
452    fn test_resolve_system_prompt_none() {
453        let result = resolve_system_prompt(None);
454        assert!(result.contains("helpful AI agent"));
455    }
456
457    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
458    // is not thread-safe and races with shell config tests. Coverage provided
459    // by shell::config::tests::test_shell_config_from_file.
460
461    #[test]
462    fn test_synaps_config_default() {
463        let config = SynapsConfig::default();
464        assert_eq!(config.model, None);
465        assert_eq!(config.thinking_budget, None);
466        assert_eq!(config.max_tool_output, 30000);
467        assert_eq!(config.bash_timeout, 30);
468        assert_eq!(config.bash_max_timeout, 300);
469        assert_eq!(config.subagent_timeout, 300);
470        assert_eq!(config.api_retries, 3);
471        assert_eq!(config.theme, None);
472        assert!(config.disabled_plugins.is_empty());
473        assert!(config.favorite_models.is_empty());
474        assert!(config.disabled_skills.is_empty());
475        assert_eq!(config.shell.max_sessions, 5);
476        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
477    }
478
479    fn make_test_home(subdir: &str) -> std::path::PathBuf {
480        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
481        let _ = std::fs::remove_dir_all(&dir);
482        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
483        dir
484    }
485
486    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
487        let original = std::env::var("HOME").ok();
488        std::env::set_var("HOME", home);
489        f();
490        if let Some(h) = original {
491            std::env::set_var("HOME", h);
492        } else {
493            std::env::remove_var("HOME");
494        }
495    }
496
497    #[test]
498    #[serial]
499    fn write_config_value_replaces_existing_key() {
500        let home = make_test_home("replace");
501        let cfg = home.join(".synaps-cli/config");
502        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
503
504        with_home(&home, || {
505            write_config_value("model", "claude-sonnet-4-6").unwrap();
506        });
507
508        let contents = std::fs::read_to_string(&cfg).unwrap();
509        assert!(contents.contains("model = claude-sonnet-4-6"));
510        assert!(contents.contains("thinking = low"));
511        let _ = std::fs::remove_dir_all(&home);
512    }
513
514    #[test]
515    #[serial]
516    fn write_config_value_appends_when_missing() {
517        let home = make_test_home("append");
518        let cfg = home.join(".synaps-cli/config");
519        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
520
521        with_home(&home, || {
522            write_config_value("theme", "dracula").unwrap();
523        });
524
525        let contents = std::fs::read_to_string(&cfg).unwrap();
526        assert!(contents.contains("model = claude-opus-4-6"));
527        assert!(contents.contains("theme = dracula"));
528        let _ = std::fs::remove_dir_all(&home);
529    }
530
531    #[test]
532    #[serial]
533    fn write_config_value_preserves_comments() {
534        let home = make_test_home("comments");
535        let cfg = home.join(".synaps-cli/config");
536        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
537
538        with_home(&home, || {
539            write_config_value("model", "claude-sonnet-4-6").unwrap();
540        });
541
542        let contents = std::fs::read_to_string(&cfg).unwrap();
543        assert!(contents.contains("# user comment"));
544        assert!(contents.contains("# another"));
545        assert!(contents.contains("model = claude-sonnet-4-6"));
546        let _ = std::fs::remove_dir_all(&home);
547    }
548
549    #[test]
550    #[serial]
551    fn write_config_value_preserves_unknown_keys() {
552        let home = make_test_home("unknown");
553        let cfg = home.join(".synaps-cli/config");
554        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
555
556        with_home(&home, || {
557            write_config_value("model", "claude-sonnet-4-6").unwrap();
558        });
559
560        let contents = std::fs::read_to_string(&cfg).unwrap();
561        assert!(contents.contains("custom_thing = 42"));
562        let _ = std::fs::remove_dir_all(&home);
563    }
564
565    #[test]
566    #[serial]
567    fn write_config_value_creates_file_if_absent() {
568        let home = make_test_home("create");
569        let cfg = home.join(".synaps-cli/config");
570        assert!(!cfg.exists());
571
572        with_home(&home, || {
573            write_config_value("model", "claude-sonnet-4-6").unwrap();
574        });
575
576        let contents = std::fs::read_to_string(&cfg).unwrap();
577        assert!(contents.contains("model = claude-sonnet-4-6"));
578        let _ = std::fs::remove_dir_all(&home);
579    }
580
581    #[test]
582    #[serial]
583    fn load_config_parses_theme_key() {
584        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
585        let _ = std::fs::create_dir_all(&dir);
586        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
587
588        let original_home = std::env::var("HOME").ok();
589        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
590
591        let config = load_config();
592
593        if let Some(home) = original_home {
594            std::env::set_var("HOME", home);
595        } else {
596            std::env::remove_var("HOME");
597        }
598        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
599
600        assert_eq!(config.theme.as_deref(), Some("dracula"));
601    }
602
603    #[test]
604    #[serial]
605    fn test_load_config_disable_lists() {
606        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
607        let _ = std::fs::create_dir_all(&test_dir);
608        let config_path = test_dir.join("config");
609
610        let config_content = r#"
611# Test config with disable lists
612favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
613
614disabled_plugins = foo, bar
615disabled_skills = baz, plug:qual
616"#;
617        std::fs::write(&config_path, config_content).unwrap();
618
619        let original_home = std::env::var("HOME").ok();
620        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
621
622        let config = load_config();
623
624        if let Some(home) = original_home {
625            std::env::set_var("HOME", home);
626        } else {
627            std::env::remove_var("HOME");
628        }
629
630        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
631
632        assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
633        assert_eq!(config.favorite_models, vec![
634            "claude/claude-opus-4-7".to_string(),
635            "groq/llama-3.3-70b-versatile".to_string(),
636        ]);
637        assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
638    }
639
640    #[test]
641    #[serial]
642    fn favorite_model_helpers_round_trip_through_config_file() {
643        let home = make_test_home("favorite-models");
644        let cfg = home.join(".synaps-cli/config");
645        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
646
647        with_home(&home, || {
648            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
649            add_favorite_model("claude/claude-opus-4-7").unwrap();
650            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
651            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
652            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
653            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
654            assert!(is_favorite_model("claude/claude-opus-4-7"));
655        });
656
657        let contents = std::fs::read_to_string(&cfg).unwrap();
658        assert!(contents.contains("model = claude-opus-4-7"));
659        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
660        let _ = std::fs::remove_dir_all(&home);
661    }
662
663    #[test]
664    #[serial]
665    fn test_load_config_new_keys() {
666        // Create a temporary config directory with the new keys
667        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
668        let _ = std::fs::create_dir_all(&test_dir);
669        let config_path = test_dir.join("config");
670        
671        let config_content = r#"
672# Test config with new keys
673model = claude-haiku
674thinking = medium
675max_tool_output = 50000
676bash_timeout = 45
677bash_max_timeout = 600
678subagent_timeout = 120
679api_retries = 5
680"#;
681        std::fs::write(&config_path, config_content).unwrap();
682        
683        // Temporarily override the config path for this test
684        let original_home = std::env::var("HOME").ok();
685        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
686        
687        let config = load_config();
688        
689        // Restore original HOME
690        if let Some(home) = original_home {
691            std::env::set_var("HOME", home);
692        } else {
693            std::env::remove_var("HOME");
694        }
695        
696        // Cleanup
697        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
698        
699        assert_eq!(config.model, Some("claude-haiku".to_string()));
700        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
701        assert_eq!(config.max_tool_output, 50000);
702        assert_eq!(config.bash_timeout, 45);
703        assert_eq!(config.bash_max_timeout, 600);
704        assert_eq!(config.subagent_timeout, 120);
705        assert_eq!(config.api_retries, 5);
706    }
707}