Skip to main content

agent_core/core/
config.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4use crate::core::shell_config::ShellConfig;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8static IDENTITY: OnceLock<String> = OnceLock::new();
9
10const DEFAULT_IDENTITY: &str = "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
11
12/// Returns the configured identity string for the system prompt preamble.
13/// Falls back to `DEFAULT_IDENTITY` (the SynapsCLI identity above) if not set
14/// in config. Initialized by `load_config()` — safe to call anytime after boot.
15pub fn get_identity() -> String {
16    IDENTITY.get().cloned().unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
17}
18
19/// Provider API keys parsed from `provider.<name> = ...` lines in config.
20/// Empty if `load_config()` hasn't been called. The registry falls back to
21/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
22pub fn get_provider_keys() -> BTreeMap<String, String> {
23    PROVIDER_KEYS.get().cloned().unwrap_or_default()
24}
25
26/// Returns the active profile name, if any.
27/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
28pub fn get_profile() -> Option<String> {
29    PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
30}
31
32/// Sets the active profile name. Must be called before any `get_profile()` call
33/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
34/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
35pub fn set_profile(name: Option<String>) {
36    let _ = PROFILE_NAME.set(name);
37}
38
39pub fn base_dir() -> PathBuf {
40    if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
41        return PathBuf::from(path);
42    }
43    let home = std::env::var("HOME")
44        .or_else(|_| std::env::var("USERPROFILE"))
45        .unwrap_or_else(|_| ".".to_string());
46    PathBuf::from(home).join(".synaps-cli")
47}
48
49/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
50#[doc(hidden)]
51pub fn set_base_dir_for_tests(path: PathBuf) {
52    std::env::set_var("SYNAPS_BASE_DIR", path);
53}
54
55/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
56pub fn resolve_read_path(filename: &str) -> PathBuf {
57    let base = base_dir();
58    
59    if let Some(profile) = get_profile() {
60        let profile_path = base.join(&profile).join(filename);
61        if profile_path.exists() {
62            return profile_path;
63        }
64    }
65    
66    base.join(filename)
67}
68
69/// Resolves a path for reading with an extended arbitrary path tree.
70pub fn resolve_read_path_extended(path: &str) -> PathBuf {
71    let base = base_dir();
72    
73    if let Some(profile) = get_profile() {
74        let profile_path = base.join(&profile).join(path);
75        if profile_path.exists() {
76            return profile_path;
77        }
78    }
79    
80    base.join(path)
81}
82
83/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
84pub fn resolve_write_path(filename: &str) -> PathBuf {
85    let mut base = base_dir();
86    
87    if let Some(profile) = get_profile() {
88        base.push(profile);
89    }
90    
91    let _ = std::fs::create_dir_all(&base);
92    base.join(filename)
93}
94
95/// Gets the absolute directory for the current profile (or root if default).
96pub fn get_active_config_dir() -> PathBuf {
97    let mut base = base_dir();
98    if let Some(profile) = get_profile() {
99        base.push(profile);
100    }
101    base
102}
103
104/// Server security configuration parsed from `server.*` keys.
105#[derive(Debug, Clone, Default)]
106pub struct ServerConfig {
107    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
108    pub allowed_origins: Vec<String>,
109    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
110    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
111    pub token: Option<String>,
112    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
113    pub auto_approve_confirms: bool,
114    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
115    /// None means no artificial cap.
116    pub max_message_size: Option<usize>,
117}
118
119/// Bridge daemon configuration parsed from `bridge.*` keys.
120///
121/// Controls best-effort mirroring of watcher heartbeats over the bridge
122/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
123/// and the feature is OFF by default.
124#[derive(Debug, Clone)]
125pub struct BridgeConfig {
126    /// Path to the bridge daemon's UDS control socket.
127    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
128    pub uds_path: Option<PathBuf>,
129    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
130    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
131    pub heartbeat_mirror: bool,
132    /// Per-call timeout in milliseconds (covers connect + write + read).
133    pub heartbeat_timeout_ms: u64,
134}
135
136impl Default for BridgeConfig {
137    fn default() -> Self {
138        Self {
139            uds_path: None,
140            heartbeat_mirror: false,
141            heartbeat_timeout_ms: 250,
142        }
143    }
144}
145
146impl BridgeConfig {
147    /// Resolve the UDS path, falling back to the default under `base_dir()`.
148    pub fn resolved_uds_path(&self) -> PathBuf {
149        self.uds_path
150            .clone()
151            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
152    }
153}
154
155/// Auth configuration parsed from `auth.*` keys. Controls whether this client
156/// resolves provider tokens from the local `auth.json` (default) or from a
157/// remote credential broker. See task #157 / `auth::credential_source`.
158#[derive(Debug, Clone, Default)]
159pub struct AuthConfig {
160    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
161    /// client fetches short-lived access tokens from the broker instead of
162    /// reading/refreshing the local `auth.json`.
163    pub remote_endpoint: Option<String>,
164    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
165    /// This is the machine's own identity, never the provider credential.
166    pub machine_token: Option<String>,
167}
168
169impl AuthConfig {
170    /// Resolve the credential source. Environment variables take precedence over
171    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
172    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
173    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
174        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
175            .ok()
176            .filter(|s| !s.trim().is_empty())
177            .or_else(|| self.remote_endpoint.clone());
178        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
179            .ok()
180            .filter(|s| !s.trim().is_empty())
181            .or_else(|| self.machine_token.clone());
182        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
183    }
184}
185
186/// Prompt-cache TTL strategy for Anthropic requests.
187///
188/// Controls the `cache_control` value emitted at every cache marker site:
189/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
190///   to historical payloads; the default path can never invalidate existing
191///   cached prefixes.
192/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
193/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
194///   bare 5m on the message-tail marker (written every turn).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum CacheTtl {
197    #[default]
198    FiveMinutes,
199    OneHour,
200    Hybrid,
201}
202
203impl CacheTtl {
204    /// Parse a config value (case-insensitive). Returns `None` for unknown
205    /// values so the caller can warn and fall back to the default.
206    pub fn parse(val: &str) -> Option<CacheTtl> {
207        match val.to_ascii_lowercase().as_str() {
208            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
209            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
210            "hybrid" => Some(CacheTtl::Hybrid),
211            _ => None,
212        }
213    }
214}
215
216/// Parsed configuration from the config file.
217#[derive(Debug, Clone)]
218pub struct SynapsConfig {
219    pub model: Option<String>,
220    pub thinking_budget: Option<u32>,
221    pub context_window: Option<u64>,   // override auto-detected context window (tokens)
222    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
223    pub max_tool_output: usize,        // default 30000
224    pub bash_timeout: u64,             // default 30
225    pub bash_max_timeout: u64,         // default 300
226    pub subagent_timeout: u64,         // default 300
227    pub api_retries: u32,              // default 3
228    pub refusal_retries: u32,          // default 2 — retries on stop_reason=refusal
229    pub telemetry: String,             // off | basic | full (default off)
230    pub cache_diagnostics: bool,       // opt into cache-diagnosis beta (default false)
231    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
232    pub cache_ttl: CacheTtl,
233    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
234    /// 144, 240). User input always redraws immediately regardless. Default
235    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
236    pub max_fps: u32,
237    pub theme: Option<String>,
238    pub agent_name: Option<String>,
239    pub identity: Option<String>,
240    pub disabled_plugins: Vec<String>,
241    pub favorite_models: Vec<String>,
242    pub disabled_skills: Vec<String>,
243    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
244    /// from the registry at boot so they're never offered to the model.
245    pub disabled_tools: Vec<String>,
246    pub shell: ShellConfig,
247    pub server: ServerConfig,
248    pub bridge: BridgeConfig,
249    pub auth: AuthConfig,
250    pub provider_keys: BTreeMap<String, String>,
251    pub keybinds: std::collections::HashMap<String, String>,
252    /// Non-fatal problems found while parsing the config file (unknown keys,
253    /// unparseable values). Surfaced once at startup — never block boot.
254    pub warnings: Vec<String>,
255}
256
257impl Default for SynapsConfig {
258    fn default() -> Self {
259        Self {
260            model: None,
261            thinking_budget: None,
262            context_window: None,
263            compaction_model: None,
264            max_tool_output: 30000,
265            bash_timeout: 30,
266            bash_max_timeout: 300,
267            subagent_timeout: 300,
268            api_retries: 3,
269            refusal_retries: 2,
270            telemetry: "off".to_string(),
271            cache_diagnostics: false,
272            cache_ttl: CacheTtl::default(),
273            max_fps: 60,
274            theme: None,
275            agent_name: None,
276            identity: None,
277            disabled_plugins: Vec::new(),
278            favorite_models: Vec::new(),
279            disabled_skills: Vec::new(),
280            disabled_tools: Vec::new(),
281            shell: ShellConfig::default(),
282            server: ServerConfig::default(),
283            bridge: BridgeConfig::default(),
284            auth: AuthConfig::default(),
285            provider_keys: BTreeMap::new(),
286            keybinds: std::collections::HashMap::new(),
287            warnings: Vec::new(),
288        }
289    }
290}
291
292/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
293const KNOWN_CONFIG_KEYS: &[&str] = &[
294    "model", "thinking", "compaction_model", "context_window", "max_tool_output",
295    "bash_timeout", "bash_max_timeout", "subagent_timeout", "api_retries", "refusal_retries",
296    "telemetry", "cache_diagnostics", "cache_ttl", "max_fps", "theme", "agent_name", "identity",
297    "disabled_plugins", "favorite_models", "disabled_skills", "disabled_tools",
298];
299
300/// Simple Levenshtein distance for did-you-mean suggestions.
301fn levenshtein(a: &str, b: &str) -> usize {
302    let a: Vec<char> = a.chars().collect();
303    let b: Vec<char> = b.chars().collect();
304    let mut prev: Vec<usize> = (0..=b.len()).collect();
305    let mut cur = vec![0; b.len() + 1];
306    for (i, ca) in a.iter().enumerate() {
307        cur[0] = i + 1;
308        for (j, cb) in b.iter().enumerate() {
309            let cost = if ca == cb { 0 } else { 1 };
310            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
311        }
312        std::mem::swap(&mut prev, &mut cur);
313    }
314    prev[b.len()]
315}
316
317/// Closest known key within edit distance 2, for typo suggestions.
318fn did_you_mean(key: &str) -> Option<&'static str> {
319    KNOWN_CONFIG_KEYS
320        .iter()
321        .map(|k| (*k, levenshtein(key, k)))
322        .filter(|(_, d)| *d <= 2)
323        .min_by_key(|(_, d)| *d)
324        .map(|(k, _)| k)
325}
326
327
328fn parse_thinking_budget(val: &str) -> Option<u32> {
329    match val {
330        "low" => Some(2048),
331        "medium" => Some(4096),
332        "high" => Some(16384),
333        "xhigh" => Some(32768),
334        "adaptive" => Some(0), // sentinel: model decides depth
335        _ => val.parse::<u32>().ok(),
336    }
337}
338
339fn parse_comma_list(val: &str) -> Vec<String> {
340    val.split(',')
341        .map(|s| s.trim().to_string())
342        .filter(|s| !s.is_empty())
343        .collect()
344}
345
346fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
347    write_config_value(key, &values.join(", "))
348}
349
350/// Parse shell.* configuration keys and update the ShellConfig.
351fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
352    match key {
353        "shell.max_sessions" => {
354            if let Ok(sessions) = val.parse::<usize>() {
355                shell_config.max_sessions = sessions;
356            } else {
357                eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
358            }
359        }
360        "shell.idle_timeout" => {
361            if let Ok(timeout) = val.parse::<u64>() {
362                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
363            } else {
364                eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
365            }
366        }
367        "shell.readiness_timeout_ms" => {
368            if let Ok(timeout) = val.parse::<u64>() {
369                shell_config.readiness_timeout_ms = timeout;
370            } else {
371                eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
372            }
373        }
374        "shell.max_readiness_timeout_ms" => {
375            if let Ok(timeout) = val.parse::<u64>() {
376                shell_config.max_readiness_timeout_ms = timeout;
377            } else {
378                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
379            }
380        }
381        "shell.default_rows" => {
382            if let Ok(rows) = val.parse::<u16>() {
383                shell_config.default_rows = rows;
384            } else {
385                eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
386            }
387        }
388        "shell.default_cols" => {
389            if let Ok(cols) = val.parse::<u16>() {
390                shell_config.default_cols = cols;
391            } else {
392                eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
393            }
394        }
395        "shell.readiness_strategy" => {
396            let val_lower = val.to_lowercase();
397            match val_lower.as_str() {
398                "timeout" | "prompt" | "hybrid" => {
399                    shell_config.readiness_strategy = val.to_string();
400                }
401                _ => {
402                    eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
403                }
404            }
405        }
406        "shell.max_output" => {
407            if let Ok(max_output) = val.parse::<usize>() {
408                shell_config.max_output = max_output;
409            } else {
410                eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
411            }
412        }
413        _ => {
414            // Unknown shell.* keys are preserved (not rejected)
415        }
416    }
417}
418
419/// Parse server.* configuration keys and update the ServerConfig.
420#[allow(clippy::collapsible_match)]
421fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
422    match key {
423        "server.allowed_origins" => {
424            server_config.allowed_origins = parse_comma_list(val);
425        }
426        "server.token" => {
427            if !val.is_empty() {
428                server_config.token = Some(val.to_string());
429            }
430        }
431        "server.auto_approve_confirms" => {
432            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
433        }
434        "server.max_message_size" => {
435            if let Ok(size) = val.parse::<usize>() {
436                server_config.max_message_size = Some(size);
437            } else {
438                eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
439            }
440        }
441        _ => {
442            // Unknown server.* keys preserved (not rejected)
443        }
444    }
445}
446
447/// Parse bridge.* configuration keys and update the BridgeConfig.
448fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
449    match key {
450        "bridge.uds_path" => {
451            if val.is_empty() {
452                bridge_config.uds_path = None;
453            } else {
454                bridge_config.uds_path = Some(PathBuf::from(val));
455            }
456        }
457        "bridge.heartbeat_mirror" => {
458            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
459        }
460        "bridge.heartbeat_timeout_ms" => {
461            if let Ok(ms) = val.parse::<u64>() {
462                bridge_config.heartbeat_timeout_ms = ms;
463            } else {
464                eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
465            }
466        }
467        _ => {
468            // Unknown bridge.* keys preserved (not rejected)
469        }
470    }
471}
472
473/// Parse auth.* configuration keys and update the AuthConfig.
474fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
475    let v = val.trim();
476    match key {
477        "auth.remote_endpoint" => {
478            auth_config.remote_endpoint = if v.is_empty() { None } else { Some(v.to_string()) };
479        }
480        "auth.machine_token" => {
481            auth_config.machine_token = if v.is_empty() { None } else { Some(v.to_string()) };
482        }
483        _ => {
484            // Unknown auth.* keys preserved (not rejected)
485        }
486    }
487}
488/// Parse the config file at ~/.synaps-cli/config (or profile variant).
489/// Returns default config if file doesn't exist or can't be read.
490pub fn load_config() -> SynapsConfig {
491    let path = resolve_read_path("config");
492    let mut config = SynapsConfig::default();
493    
494    let Ok(content) = std::fs::read_to_string(&path) else {
495        return config;
496    };
497    
498    for line in content.lines() {
499        let line = line.trim();
500        if line.is_empty() || line.starts_with('#') { continue; }
501        let Some((key, val)) = line.split_once('=') else { continue };
502        let key = key.trim();
503        let val = val.trim();
504        match key {
505            "model" => config.model = Some(val.to_string()),
506            "thinking" => {
507                config.thinking_budget = parse_thinking_budget(val);
508                if config.thinking_budget.is_none() {
509                    config.warnings.push(format!("thinking = {val} — expected low|medium|high|xhigh|adaptive or a token count; thinking disabled"));
510                }
511            }
512            "compaction_model" => config.compaction_model = Some(val.to_string()),
513            "context_window" => {
514                let parsed = match val {
515                    "200k" | "200K" => Some(200_000),
516                    "1m" | "1M" => Some(1_000_000),
517                    _ => val.parse::<u64>().ok(),
518                };
519                if parsed.is_none() {
520                    config.warnings.push(format!("context_window = {val} — expected 200k, 1m, or a token count; ignored"));
521                }
522                config.context_window = parsed;
523            }
524            "max_tool_output" => {
525                match val.parse::<usize>() {
526                    Ok(size) => config.max_tool_output = size,
527                    Err(_) => config.warnings.push(format!("max_tool_output = {val} — not a number; using {}", config.max_tool_output)),
528                }
529            }
530            "bash_timeout" => {
531                match val.parse::<u64>() {
532                    Ok(t) if t >= 1 => config.bash_timeout = t,
533                    Ok(_) => config.warnings.push(format!("bash_timeout = {val} — below minimum (1s); using {}", config.bash_timeout)),
534                    Err(_) => config.warnings.push(format!("bash_timeout = {val} — not a number; using {}", config.bash_timeout)),
535                }
536            }
537            "bash_max_timeout" => {
538                if let Ok(timeout) = val.parse::<u64>() {
539                    config.bash_max_timeout = timeout;
540                }
541            }
542            "subagent_timeout" => {
543                if let Ok(timeout) = val.parse::<u64>() {
544                    config.subagent_timeout = timeout;
545                }
546            }
547            "api_retries" => {
548                if let Ok(retries) = val.parse::<u32>() {
549                    config.api_retries = retries;
550                }
551            }
552            "refusal_retries" => {
553                if let Ok(retries) = val.parse::<u32>() {
554                    config.refusal_retries = retries;
555                }
556            }
557            "telemetry" => config.telemetry = val.to_string(),
558            "cache_diagnostics" => {
559                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
560            }
561            "cache_ttl" => {
562                match CacheTtl::parse(val) {
563                    Some(ttl) => config.cache_ttl = ttl,
564                    None => config.warnings.push(format!(
565                        "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
566                    )),
567                }
568            }
569            "max_fps" => {
570                match val.parse::<u32>() {
571                    Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
572                    Ok(_) => config.warnings.push(format!(
573                        "max_fps = {val} — expected 1–1000; using {}", config.max_fps
574                    )),
575                    Err(_) => config.warnings.push(format!(
576                        "max_fps = {val} — not a number; using {}", config.max_fps
577                    )),
578                }
579            }
580            "theme" => config.theme = Some(val.to_string()),
581            "agent_name" => config.agent_name = Some(val.to_string()),
582            "identity" => config.identity = Some(val.to_string()),
583            "disabled_plugins" => {
584                config.disabled_plugins = parse_comma_list(val);
585            }
586            "favorite_models" => {
587                config.favorite_models = parse_comma_list(val);
588            }
589            "disabled_skills" => {
590                config.disabled_skills = parse_comma_list(val);
591            }
592            "disabled_tools" => {
593                config.disabled_tools = parse_comma_list(val);
594            }
595            _ => {
596                // Handle namespaced keys
597                if key.starts_with("shell.") {
598                    parse_shell_config_key(&mut config.shell, key, val);
599                } else if key.starts_with("server.") {
600                    parse_server_config_key(&mut config.server, key, val);
601                } else if key.starts_with("bridge.") {
602                    parse_bridge_config_key(&mut config.bridge, key, val);
603                } else if key.starts_with("auth.") {
604                    parse_auth_config_key(&mut config.auth, key, val);
605                } else if let Some(provider_key) = key.strip_prefix("provider.") {
606                    config.provider_keys.insert(provider_key.to_string(), val.to_string());
607                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
608                    config.keybinds.insert(keybind_key.to_string(), val.to_string());
609                } else if key.contains('.') {
610                    // Dotted keys are namespaced (plugin/extension config, e.g.
611                    // `knowledge.jawz_notes`). Plugins define their own keys —
612                    // not ours to police. Silently preserved.
613                } else {
614                    // Unknown top-level key — warn with a did-you-mean if close.
615                    match did_you_mean(key) {
616                        Some(suggestion) => config.warnings.push(format!("unknown key '{key}' (did you mean '{suggestion}'?)")),
617                        None => config.warnings.push(format!("unknown key '{key}' — ignored")),
618                    }
619                }
620            }
621        }
622    }
623
624    // Derive max_message_size from context_window if not explicitly set.
625    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
626    if config.server.max_message_size.is_none() {
627        if let Some(ctx_tokens) = config.context_window {
628            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
629        }
630    }
631
632    // Publish provider keys to the process-wide cache for the API router.
633    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
634    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
635
636    // Publish identity to the process-wide cache for API system prompt preamble.
637    let identity_val = config.identity.clone().unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
638    let _ = IDENTITY.set(identity_val);
639
640    config
641}
642
643/// Read a single config value by exact key from the active config file.
644pub fn read_config_value(key: &str) -> Option<String> {
645    let path = resolve_read_path("config");
646    let content = std::fs::read_to_string(&path).ok()?;
647    for line in content.lines() {
648        let line = line.trim();
649        if line.is_empty() || line.starts_with('#') { continue; }
650        let Some((k, v)) = line.split_once('=') else { continue };
651        if k.trim() == key.trim() {
652            return Some(v.trim().to_string());
653        }
654    }
655    None
656}
657
658/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
659/// Replaces the first existing line that matches the key, or appends if absent.
660/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
661pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
662    let path = resolve_write_path("config");
663    let existing = std::fs::read_to_string(&path).unwrap_or_default();
664
665    let key_trimmed = key.trim();
666    let replacement = format!("{} = {}", key_trimmed, value);
667
668    let mut found = false;
669    let mut new_lines: Vec<String> = existing.lines().map(|line| {
670        if found { return line.to_string(); }
671        let t = line.trim_start();
672        if t.starts_with('#') || t.is_empty() { return line.to_string(); }
673        if let Some((k, _)) = t.split_once('=') {
674            if k.trim() == key_trimmed {
675                found = true;
676                return replacement.clone();
677            }
678        }
679        line.to_string()
680    }).collect();
681
682    if !found {
683        new_lines.push(replacement);
684    }
685
686    let mut out = new_lines.join("\n");
687    if !out.ends_with('\n') { out.push('\n'); }
688
689    let tmp = path.with_extension("tmp");
690    std::fs::write(&tmp, out)?;
691    // Config may contain API keys — restrict to owner-only
692    #[cfg(unix)]
693    {
694        use std::os::unix::fs::PermissionsExt;
695        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
696    }
697    std::fs::rename(&tmp, &path)?;
698    Ok(())
699}
700
701/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
702pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
703    let trimmed = id.trim();
704    if trimmed.is_empty() {
705        return Ok(());
706    }
707    let mut values = load_config().favorite_models;
708    if !values.iter().any(|v| v == trimmed) {
709        values.push(trimmed.to_string());
710        values.sort();
711    }
712    write_comma_list("favorite_models", &values)
713}
714
715/// Remove a favorite model id (`provider/model`) from config.
716pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
717    let mut values = load_config().favorite_models;
718    values.retain(|v| v != id.trim());
719    write_comma_list("favorite_models", &values)
720}
721
722/// Return whether a model id is marked as favorite.
723pub fn is_favorite_model(id: &str) -> bool {
724    load_config().favorite_models.iter().any(|v| v == id.trim())
725}
726
727/// Resolve the system prompt from CLI flag, config file, or default.
728/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
729pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
730    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
731        You have access to bash, read, and write tools. \
732        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
733
734    if let Some(val) = explicit {
735        let path = std::path::Path::new(val);
736        if path.exists() && path.is_file() {
737            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
738        }
739        return val.to_string();
740    }
741
742    let system_path = resolve_read_path("system.md");
743    if system_path.exists() {
744        return std::fs::read_to_string(&system_path).unwrap_or_default();
745    }
746
747    DEFAULT_PROMPT.to_string()
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use serial_test::serial;
754
755    #[test]
756    fn test_levenshtein_basics() {
757        assert_eq!(levenshtein("model", "model"), 0);
758        assert_eq!(levenshtein("modle", "model"), 2);
759        assert_eq!(levenshtein("them", "theme"), 1);
760    }
761
762    #[test]
763    fn test_did_you_mean_close_typos() {
764        assert_eq!(did_you_mean("modle"), Some("model"));
765        assert_eq!(did_you_mean("them"), Some("theme"));
766        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
767        assert_eq!(did_you_mean("completely_unrelated_key"), None);
768    }
769
770    #[test]
771    #[serial]
772    fn test_config_warnings_unknown_key_and_bad_values() {
773        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
774        let dir = home.join(".synaps-cli");
775        std::fs::create_dir_all(&dir).unwrap();
776        std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
777
778        with_home(&home, || {
779            let config = load_config();
780            // Dotted (namespaced) keys must NOT warn — plugins own those.
781            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
782            assert!(!config.warnings.iter().any(|w| w.contains("knowledge")), "{:?}", config.warnings);
783            assert!(config.warnings.iter().any(|w| w.contains("did you mean 'model'")), "{:?}", config.warnings);
784            assert!(config.warnings.iter().any(|w| w.contains("thinking")), "{:?}", config.warnings);
785            assert!(config.warnings.iter().any(|w| w.contains("below minimum")), "{:?}", config.warnings);
786            // Bad values fall back to defaults
787            assert_eq!(config.bash_timeout, 30);
788            assert_eq!(config.thinking_budget, None);
789        });
790        let _ = std::fs::remove_dir_all(&home);
791    }
792
793    // ── cache_ttl parse table (spec §3.1) ──
794
795    #[test]
796    fn test_cache_ttl_parse_table() {
797        // 5m aliases
798        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
799        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
800        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
801        // 1h aliases
802        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
803        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
804        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
805        // hybrid
806        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
807        // case-insensitive
808        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
809        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
810        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
811        // garbage → None (caller warns + defaults)
812        assert_eq!(CacheTtl::parse("2h"), None);
813        assert_eq!(CacheTtl::parse(""), None);
814        assert_eq!(CacheTtl::parse("forever"), None);
815    }
816
817    #[test]
818    fn test_cache_ttl_default_is_five_minutes() {
819        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
820        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
821    }
822
823    #[test]
824    fn test_max_fps_default_is_60() {
825        assert_eq!(SynapsConfig::default().max_fps, 60);
826    }
827
828    #[test]
829    #[serial]
830    fn test_max_fps_config_parse_and_validation() {
831        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
832        let dir = home.join(".synaps-cli");
833        std::fs::create_dir_all(&dir).unwrap();
834
835        // Valid high-refresh value parses, no warning.
836        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
837        with_home(&home, || {
838            let config = load_config();
839            assert_eq!(config.max_fps, 144);
840            assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
841        });
842
843        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
844        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
845        with_home(&home, || {
846            let config = load_config();
847            assert_eq!(config.max_fps, 60);
848            assert!(
849                config.warnings.iter().any(|w| w.contains("max_fps")),
850                "warnings: {:?}", config.warnings
851            );
852        });
853
854        // Non-numeric → default 60 + warning.
855        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
856        with_home(&home, || {
857            let config = load_config();
858            assert_eq!(config.max_fps, 60);
859            assert!(
860                config.warnings.iter().any(|w| w.contains("max_fps")),
861                "warnings: {:?}", config.warnings
862            );
863        });
864
865        let _ = std::fs::remove_dir_all(&home);
866    }
867
868    #[test]
869    #[serial]
870    fn test_cache_ttl_config_parse_and_garbage_warning() {
871        let home = std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
872        let dir = home.join(".synaps-cli");
873        std::fs::create_dir_all(&dir).unwrap();
874
875        // Valid value parses, no warning.
876        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
877        with_home(&home, || {
878            let config = load_config();
879            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
880            assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
881        });
882
883        // Garbage value → 5m default + boot warning (never blocks boot).
884        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
885        with_home(&home, || {
886            let config = load_config();
887            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
888            assert!(
889                config.warnings.iter().any(|w| w.contains("cache_ttl")),
890                "warnings: {:?}", config.warnings
891            );
892        });
893
894        let _ = std::fs::remove_dir_all(&home);
895    }
896
897    #[test]
898    fn test_parse_thinking_budget() {
899        assert_eq!(parse_thinking_budget("low"), Some(2048));
900        assert_eq!(parse_thinking_budget("medium"), Some(4096));
901        assert_eq!(parse_thinking_budget("high"), Some(16384));
902        assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
903        assert_eq!(parse_thinking_budget("8192"), Some(8192));
904        assert_eq!(parse_thinking_budget("invalid"), None);
905    }
906
907    #[test]
908    fn test_base_dir() {
909        let path = base_dir();
910        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
911    }
912
913    #[test]
914    fn test_resolve_system_prompt_explicit() {
915        let result = resolve_system_prompt(Some("test prompt"));
916        assert_eq!(result, "test prompt");
917    }
918
919    #[test]
920    fn test_resolve_system_prompt_none() {
921        let result = resolve_system_prompt(None);
922        assert!(result.contains("helpful AI agent"));
923    }
924
925    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
926    // is not thread-safe and races with shell config tests. Coverage provided
927    // by shell::config::tests::test_shell_config_from_file.
928
929    #[test]
930    fn test_synaps_config_default() {
931        let config = SynapsConfig::default();
932        assert_eq!(config.model, None);
933        assert_eq!(config.thinking_budget, None);
934        assert_eq!(config.max_tool_output, 30000);
935        assert_eq!(config.bash_timeout, 30);
936        assert_eq!(config.bash_max_timeout, 300);
937        assert_eq!(config.subagent_timeout, 300);
938        assert_eq!(config.api_retries, 3);
939        assert_eq!(config.theme, None);
940        assert!(config.disabled_plugins.is_empty());
941        assert!(config.favorite_models.is_empty());
942        assert!(config.disabled_skills.is_empty());
943        assert_eq!(config.shell.max_sessions, 5);
944        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
945        // Server config defaults
946        assert!(config.server.allowed_origins.is_empty());
947        assert_eq!(config.server.token, None);
948        assert!(!config.server.auto_approve_confirms);
949        assert_eq!(config.server.max_message_size, None);
950        // Bridge config defaults
951        assert!(config.bridge.uds_path.is_none());
952        assert!(!config.bridge.heartbeat_mirror);
953        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
954    }
955
956    #[test]
957    #[serial]
958    fn test_load_config_bridge_keys() {
959        let home = make_test_home("bridge-keys");
960        let cfg = home.join(".synaps-cli/config");
961        std::fs::write(&cfg, "\
962bridge.uds_path = /tmp/some/control.sock\n\
963bridge.heartbeat_mirror = true\n\
964bridge.heartbeat_timeout_ms = 750\n\
965").unwrap();
966
967        with_home(&home, || {
968            let config = load_config();
969            assert_eq!(
970                config.bridge.uds_path,
971                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
972            );
973            assert!(config.bridge.heartbeat_mirror);
974            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
975            assert_eq!(
976                config.bridge.resolved_uds_path(),
977                std::path::PathBuf::from("/tmp/some/control.sock"),
978            );
979        });
980
981        let _ = std::fs::remove_dir_all(&home);
982    }
983
984    #[test]
985    fn test_bridge_config_defaults() {
986        let cfg = BridgeConfig::default();
987        assert!(cfg.uds_path.is_none());
988        assert!(!cfg.heartbeat_mirror);
989        assert_eq!(cfg.heartbeat_timeout_ms, 250);
990        // resolved path falls under base_dir()/bridge/control.sock
991        let resolved = cfg.resolved_uds_path();
992        assert!(resolved.ends_with("bridge/control.sock"));
993    }
994
995    #[test]
996    #[serial]
997    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
998        let home = make_test_home("bridge-default-off");
999        let cfg = home.join(".synaps-cli/config");
1000        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1001
1002        with_home(&home, || {
1003            let config = load_config();
1004            assert!(!config.bridge.heartbeat_mirror);
1005            assert!(config.bridge.uds_path.is_none());
1006            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1007        });
1008
1009        let _ = std::fs::remove_dir_all(&home);
1010    }
1011
1012    #[test]
1013    #[serial]
1014    fn test_load_config_server_keys() {
1015        let home = make_test_home("server-keys");
1016        let cfg = home.join(".synaps-cli/config");
1017        std::fs::write(&cfg, "\
1018server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1019server.token = my-secret-token\n\
1020server.auto_approve_confirms = true\n\
1021server.max_message_size = 65536\n\
1022context_window = 200k\n\
1023").unwrap();
1024
1025        with_home(&home, || {
1026            let config = load_config();
1027            assert_eq!(config.server.allowed_origins, vec![
1028                "http://localhost:3000".to_string(),
1029                "http://localhost:5193".to_string(),
1030            ]);
1031            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1032            assert!(config.server.auto_approve_confirms);
1033            // Explicit max_message_size takes precedence over context_window derivation
1034            assert_eq!(config.server.max_message_size, Some(65536));
1035        });
1036
1037        let _ = std::fs::remove_dir_all(&home);
1038    }
1039
1040    #[test]
1041    #[serial]
1042    fn test_server_max_message_size_derived_from_context_window() {
1043        let home = make_test_home("server-derive");
1044        let cfg = home.join(".synaps-cli/config");
1045        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1046
1047        with_home(&home, || {
1048            let config = load_config();
1049            // 200_000 tokens * 4 bytes/token = 800_000 bytes
1050            assert_eq!(config.server.max_message_size, Some(800_000));
1051        });
1052
1053        let _ = std::fs::remove_dir_all(&home);
1054    }
1055
1056    fn make_test_home(subdir: &str) -> std::path::PathBuf {
1057        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1058        let _ = std::fs::remove_dir_all(&dir);
1059        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1060        dir
1061    }
1062
1063    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1064        let original = std::env::var("HOME").ok();
1065        std::env::set_var("HOME", home);
1066        f();
1067        if let Some(h) = original {
1068            std::env::set_var("HOME", h);
1069        } else {
1070            std::env::remove_var("HOME");
1071        }
1072    }
1073
1074    #[test]
1075    #[serial]
1076    fn write_config_value_replaces_existing_key() {
1077        let home = make_test_home("replace");
1078        let cfg = home.join(".synaps-cli/config");
1079        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1080
1081        with_home(&home, || {
1082            write_config_value("model", "claude-sonnet-4-6").unwrap();
1083        });
1084
1085        let contents = std::fs::read_to_string(&cfg).unwrap();
1086        assert!(contents.contains("model = claude-sonnet-4-6"));
1087        assert!(contents.contains("thinking = low"));
1088        let _ = std::fs::remove_dir_all(&home);
1089    }
1090
1091    #[test]
1092    #[serial]
1093    fn write_config_value_appends_when_missing() {
1094        let home = make_test_home("append");
1095        let cfg = home.join(".synaps-cli/config");
1096        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1097
1098        with_home(&home, || {
1099            write_config_value("theme", "dracula").unwrap();
1100        });
1101
1102        let contents = std::fs::read_to_string(&cfg).unwrap();
1103        assert!(contents.contains("model = claude-opus-4-6"));
1104        assert!(contents.contains("theme = dracula"));
1105        let _ = std::fs::remove_dir_all(&home);
1106    }
1107
1108    #[test]
1109    #[serial]
1110    fn write_config_value_preserves_comments() {
1111        let home = make_test_home("comments");
1112        let cfg = home.join(".synaps-cli/config");
1113        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1114
1115        with_home(&home, || {
1116            write_config_value("model", "claude-sonnet-4-6").unwrap();
1117        });
1118
1119        let contents = std::fs::read_to_string(&cfg).unwrap();
1120        assert!(contents.contains("# user comment"));
1121        assert!(contents.contains("# another"));
1122        assert!(contents.contains("model = claude-sonnet-4-6"));
1123        let _ = std::fs::remove_dir_all(&home);
1124    }
1125
1126    #[test]
1127    #[serial]
1128    fn write_config_value_preserves_unknown_keys() {
1129        let home = make_test_home("unknown");
1130        let cfg = home.join(".synaps-cli/config");
1131        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1132
1133        with_home(&home, || {
1134            write_config_value("model", "claude-sonnet-4-6").unwrap();
1135        });
1136
1137        let contents = std::fs::read_to_string(&cfg).unwrap();
1138        assert!(contents.contains("custom_thing = 42"));
1139        let _ = std::fs::remove_dir_all(&home);
1140    }
1141
1142    #[test]
1143    #[serial]
1144    fn write_config_value_creates_file_if_absent() {
1145        let home = make_test_home("create");
1146        let cfg = home.join(".synaps-cli/config");
1147        assert!(!cfg.exists());
1148
1149        with_home(&home, || {
1150            write_config_value("model", "claude-sonnet-4-6").unwrap();
1151        });
1152
1153        let contents = std::fs::read_to_string(&cfg).unwrap();
1154        assert!(contents.contains("model = claude-sonnet-4-6"));
1155        let _ = std::fs::remove_dir_all(&home);
1156    }
1157
1158    #[test]
1159    #[serial]
1160    fn load_config_parses_theme_key() {
1161        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1162        let _ = std::fs::create_dir_all(&dir);
1163        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1164
1165        let original_home = std::env::var("HOME").ok();
1166        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1167
1168        let config = load_config();
1169
1170        if let Some(home) = original_home {
1171            std::env::set_var("HOME", home);
1172        } else {
1173            std::env::remove_var("HOME");
1174        }
1175        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1176
1177        assert_eq!(config.theme.as_deref(), Some("dracula"));
1178    }
1179
1180    #[test]
1181    #[serial]
1182    fn test_load_config_disable_lists() {
1183        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1184        let _ = std::fs::create_dir_all(&test_dir);
1185        let config_path = test_dir.join("config");
1186
1187        let config_content = r#"
1188# Test config with disable lists
1189favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1190
1191disabled_plugins = foo, bar
1192disabled_skills = baz, plug:qual
1193"#;
1194        std::fs::write(&config_path, config_content).unwrap();
1195
1196        let original_home = std::env::var("HOME").ok();
1197        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1198
1199        let config = load_config();
1200
1201        if let Some(home) = original_home {
1202            std::env::set_var("HOME", home);
1203        } else {
1204            std::env::remove_var("HOME");
1205        }
1206
1207        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1208
1209        assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
1210        assert_eq!(config.favorite_models, vec![
1211            "claude/claude-opus-4-7".to_string(),
1212            "groq/llama-3.3-70b-versatile".to_string(),
1213        ]);
1214        assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
1215    }
1216
1217    #[test]
1218    #[serial]
1219    fn favorite_model_helpers_round_trip_through_config_file() {
1220        let home = make_test_home("favorite-models");
1221        let cfg = home.join(".synaps-cli/config");
1222        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
1223
1224        with_home(&home, || {
1225            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1226            add_favorite_model("claude/claude-opus-4-7").unwrap();
1227            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1228            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1229            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1230            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1231            assert!(is_favorite_model("claude/claude-opus-4-7"));
1232        });
1233
1234        let contents = std::fs::read_to_string(&cfg).unwrap();
1235        assert!(contents.contains("model = claude-opus-4-7"));
1236        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1237        let _ = std::fs::remove_dir_all(&home);
1238    }
1239
1240    #[test]
1241    #[serial]
1242    fn test_load_config_new_keys() {
1243        // Create a temporary config directory with the new keys
1244        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1245        let _ = std::fs::create_dir_all(&test_dir);
1246        let config_path = test_dir.join("config");
1247        
1248        let config_content = r#"
1249# Test config with new keys
1250model = claude-haiku
1251thinking = medium
1252max_tool_output = 50000
1253bash_timeout = 45
1254bash_max_timeout = 600
1255subagent_timeout = 120
1256api_retries = 5
1257"#;
1258        std::fs::write(&config_path, config_content).unwrap();
1259        
1260        // Temporarily override the config path for this test
1261        let original_home = std::env::var("HOME").ok();
1262        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
1263        
1264        let config = load_config();
1265        
1266        // Restore original HOME
1267        if let Some(home) = original_home {
1268            std::env::set_var("HOME", home);
1269        } else {
1270            std::env::remove_var("HOME");
1271        }
1272        
1273        // Cleanup
1274        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
1275        
1276        assert_eq!(config.model, Some("claude-haiku".to_string()));
1277        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
1278        assert_eq!(config.max_tool_output, 50000);
1279        assert_eq!(config.bash_timeout, 45);
1280        assert_eq!(config.bash_max_timeout, 600);
1281        assert_eq!(config.subagent_timeout, 120);
1282        assert_eq!(config.api_retries, 5);
1283    }
1284
1285    // ── auth.* config + credential source (#157) ──
1286
1287    #[test]
1288    #[serial]
1289    fn test_auth_config_parses_endpoint_and_token() {
1290        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
1291        let dir = home.join(".synaps-cli");
1292        std::fs::create_dir_all(&dir).unwrap();
1293        std::fs::write(
1294            dir.join("config"),
1295            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
1296        )
1297        .unwrap();
1298        with_home(&home, || {
1299            let config = load_config();
1300            assert_eq!(config.auth.remote_endpoint.as_deref(), Some("https://jade.jade:8181"));
1301            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
1302            // auth.* are namespaced -> must NOT warn as unknown keys
1303            assert!(!config.warnings.iter().any(|w| w.contains("auth.")), "{:?}", config.warnings);
1304        });
1305        let _ = std::fs::remove_dir_all(&home);
1306    }
1307
1308    #[test]
1309    #[serial]
1310    fn test_credential_source_local_by_default() {
1311        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1312        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1313        assert!(!AuthConfig::default().credential_source().is_remote());
1314    }
1315
1316    #[test]
1317    #[serial]
1318    fn test_credential_source_remote_from_config() {
1319        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1320        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1321        let auth = AuthConfig {
1322            remote_endpoint: Some("https://b".into()),
1323            machine_token: Some("m".into()),
1324        };
1325        assert_eq!(
1326            auth.credential_source(),
1327            crate::core::auth::CredentialSource::Remote { endpoint: "https://b".into(), machine_token: "m".into() }
1328        );
1329    }
1330
1331    #[test]
1332    #[serial]
1333    fn test_credential_source_env_overrides_config() {
1334        let auth = AuthConfig {
1335            remote_endpoint: Some("https://config-host".into()),
1336            machine_token: Some("config-tok".into()),
1337        };
1338        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
1339        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
1340        let src = auth.credential_source();
1341        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1342        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1343        assert_eq!(
1344            src,
1345            crate::core::auth::CredentialSource::Remote { endpoint: "https://env-host".into(), machine_token: "env-tok".into() }
1346        );
1347    }
1348}