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
9pub fn get_provider_keys() -> BTreeMap<String, String> {
13 PROVIDER_KEYS.get().cloned().unwrap_or_default()
14}
15
16pub fn get_profile() -> Option<String> {
19 PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
20}
21
22pub 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#[doc(hidden)]
41pub fn set_base_dir_for_tests(path: PathBuf) {
42 std::env::set_var("SYNAPS_BASE_DIR", path);
43}
44
45pub 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
59pub 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
73pub 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
85pub 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#[derive(Debug, Clone)]
96pub struct SynapsConfig {
97 pub model: Option<String>,
98 pub thinking_budget: Option<u32>,
99 pub context_window: Option<u64>, pub compaction_model: Option<String>, pub max_tool_output: usize, pub bash_timeout: u64, pub bash_max_timeout: u64, pub subagent_timeout: u64, pub api_retries: u32, 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), _ => 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
163fn 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 }
229 }
230}
231
232pub 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 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 }
307 }
308 }
309
310 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
313
314 config
315}
316
317pub 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
332pub 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 #[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
375pub 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
389pub 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
396pub fn is_favorite_model(id: &str) -> bool {
398 load_config().favorite_models.iter().any(|v| v == id.trim())
399}
400
401pub 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 #[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 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 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 if let Some(home) = original_home {
691 std::env::set_var("HOME", home);
692 } else {
693 std::env::remove_var("HOME");
694 }
695
696 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)); 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}