Skip to main content

kiss_coding/
settings.rs

1//! Settings: global `~/.kiss/agent/settings.json` deep-merged with project
2//! `.kiss/settings.json` (project wins).
3
4use kiss_ai::Transport;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum CompactionMode {
13    #[default]
14    Summary,
15    Jev,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase", default)]
20pub struct CompactionSettings {
21    pub enabled: bool,
22    pub mode: CompactionMode,
23    pub reserve_tokens: u64,
24    pub keep_recent_tokens: u64,
25    pub model_overrides: BTreeMap<String, CompactionModelOverride>,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "camelCase", default)]
30pub struct CompactionModelOverride {
31    pub reserve_tokens: Option<u64>,
32    pub keep_recent_tokens: Option<u64>,
33}
34
35impl Default for CompactionSettings {
36    fn default() -> Self {
37        CompactionSettings {
38            enabled: true,
39            mode: CompactionMode::Summary,
40            reserve_tokens: 16_384,
41            keep_recent_tokens: 20_000,
42            model_overrides: BTreeMap::new(),
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "lowercase")]
49pub enum ReasoningEffortMode {
50    #[default]
51    Fixed,
52    Jev,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56#[serde(rename_all = "camelCase", default)]
57pub struct ReasoningEffortSettings {
58    pub mode: ReasoningEffortMode,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", default)]
63pub struct RetrySettings {
64    pub enabled: bool,
65    pub max_retries: u32,
66    pub base_delay_ms: u64,
67    pub max_agent_delay_ms: u64,
68}
69
70impl Default for RetrySettings {
71    fn default() -> Self {
72        RetrySettings {
73            enabled: true,
74            max_retries: 3,
75            base_delay_ms: 2000,
76            max_agent_delay_ms: 60_000,
77        }
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "camelCase", default)]
83pub struct SubagentSettings {
84    /// Subagent tools are hidden until the user enables them.
85    pub enabled: bool,
86}
87
88/// How many agents to advise the model to aim for when it writes a workflow.
89///
90/// This is advice sent to the model, not a cap: a prompt that clearly calls for
91/// a different scale still wins. The runtime limits are the real bound.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
93#[serde(rename_all = "lowercase")]
94pub enum WorkflowSize {
95    Small,
96    #[default]
97    Medium,
98    Large,
99    Unrestricted,
100}
101
102impl WorkflowSize {
103    pub fn as_str(self) -> &'static str {
104        match self {
105            Self::Small => "small",
106            Self::Medium => "medium",
107            Self::Large => "large",
108            Self::Unrestricted => "unrestricted",
109        }
110    }
111
112    /// The agent count the model is asked to stay under, if any.
113    pub fn target_agents(self) -> Option<u32> {
114        match self {
115            Self::Small => Some(5),
116            Self::Medium => Some(15),
117            Self::Large => Some(50),
118            Self::Unrestricted => None,
119        }
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase", default)]
125pub struct WorkflowSettings {
126    /// Dynamic workflows are built on subagents, so they are available only
127    /// when `subagents.enabled` is also on.
128    pub enabled: bool,
129    /// Ask before a run starts. This is a cost gate, not a permission gate: one
130    /// run can start hundreds of child agents.
131    pub confirm: bool,
132    /// Let a typed prompt containing `ultracode`, or asking for a workflow in
133    /// plain words, start one.
134    pub keyword_trigger: bool,
135    pub size: WorkflowSize,
136}
137
138impl Default for WorkflowSettings {
139    fn default() -> Self {
140        WorkflowSettings {
141            enabled: true,
142            confirm: true,
143            keyword_trigger: true,
144            size: WorkflowSize::default(),
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
150#[serde(rename_all = "kebab-case")]
151pub enum QueueMode {
152    All,
153    #[default]
154    OneAtATime,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
158#[serde(rename_all = "lowercase")]
159pub enum ProjectTrustDefault {
160    #[default]
161    Ask,
162    Always,
163    Never,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
167#[serde(rename_all = "lowercase")]
168pub enum CacheWarmingMode {
169    Off,
170    #[default]
171    Streaming,
172    Idle,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
176#[serde(rename_all = "lowercase")]
177pub enum MermaidRendering {
178    Off,
179    Final,
180    #[default]
181    Streaming,
182}
183
184impl MermaidRendering {
185    pub fn as_str(self) -> &'static str {
186        match self {
187            Self::Off => "off",
188            Self::Final => "final",
189            Self::Streaming => "streaming",
190        }
191    }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
195#[serde(rename_all = "camelCase", default)]
196pub struct MarkdownSettings {
197    pub code_block_indent: Option<String>,
198    pub mermaid: MermaidRendering,
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
202#[serde(rename_all = "camelCase", default)]
203pub struct WebMcpSettings {
204    pub allowed_origins: Option<Vec<String>>,
205    pub disallowed_origins: Vec<String>,
206    pub cdp: Option<Value>,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
210#[serde(rename_all = "camelCase", default)]
211pub struct Settings {
212    pub default_provider: Option<String>,
213    pub default_model: Option<String>,
214    pub default_thinking_level: Option<String>,
215    pub transport: Transport,
216    pub hide_thinking_block: bool,
217    pub theme: Option<String>,
218    pub quiet_startup: bool,
219    pub default_project_trust: ProjectTrustDefault,
220    pub compaction: CompactionSettings,
221    pub reasoning_effort: ReasoningEffortSettings,
222    pub retry: RetrySettings,
223    pub cache_warming: CacheWarmingMode,
224    pub subagents: SubagentSettings,
225    pub workflows: WorkflowSettings,
226    pub steering_mode: QueueMode,
227    pub follow_up_mode: QueueMode,
228    pub shell_path: Option<String>,
229    pub shell_command_prefix: Option<String>,
230    pub session_dir: Option<String>,
231    pub enabled_models: Option<Vec<String>>,
232    pub default_tools: Option<Vec<String>>,
233    pub external_editor: Option<String>,
234    pub skills: Vec<String>,
235    pub prompts: Vec<String>,
236    pub themes: Vec<String>,
237    pub enable_skill_commands: Option<bool>,
238    /// `None` preserves the default-on behavior for existing settings files.
239    pub auto_recap: Option<bool>,
240    pub voice_language: Option<String>,
241    pub voice_backend: Option<String>,
242    pub markdown: MarkdownSettings,
243    pub webmcp: WebMcpSettings,
244    /// Unknown keys survive load/save.
245    #[serde(flatten)]
246    pub extra: serde_json::Map<String, Value>,
247}
248
249pub fn global_settings_path() -> Option<PathBuf> {
250    dirs::home_dir().map(|h| h.join(".kiss/agent/settings.json"))
251}
252
253pub fn project_settings_path(cwd: &Path) -> PathBuf {
254    cwd.join(".kiss/settings.json")
255}
256
257fn read_json(path: &Path) -> Option<Value> {
258    let text = std::fs::read_to_string(path).ok()?;
259    serde_json::from_str(&text).ok()
260}
261
262/// Deep-merge `overlay` onto `base` (objects merge, everything else replaces).
263fn deep_merge(base: &mut Value, overlay: Value) {
264    match (base, overlay) {
265        (Value::Object(base_map), Value::Object(overlay_map)) => {
266            for (k, v) in overlay_map {
267                match base_map.get_mut(&k) {
268                    Some(slot) => deep_merge(slot, v),
269                    None => {
270                        base_map.insert(k, v);
271                    }
272                }
273            }
274        }
275        (slot, v) => *slot = v,
276    }
277}
278
279fn merged_settings(mut global: Value, project: Option<Value>) -> Settings {
280    let subagents_enabled = global
281        .pointer("/subagents/enabled")
282        .and_then(Value::as_bool)
283        .unwrap_or(false);
284    let workflows_enabled = global
285        .pointer("/workflows/enabled")
286        .and_then(Value::as_bool)
287        .unwrap_or(true);
288    // A project must not opt the user's microphone in to a cloud service.
289    let voice_backend = global
290        .get("voiceBackend")
291        .and_then(Value::as_str)
292        .map(str::to_owned);
293    let cache_warming = global
294        .get("cacheWarming")
295        .cloned()
296        .and_then(|value| serde_json::from_value(value).ok())
297        .unwrap_or_default();
298    if let Some(mut project) = project {
299        if let Some(object) = project.as_object_mut() {
300            object.remove("deviceId");
301        }
302        if let Some(entries) = project.get("defaultTools").and_then(Value::as_array)
303            && entries.iter().all(|entry| {
304                entry
305                    .as_str()
306                    .is_some_and(|name| name.starts_with(['+', '-']))
307            })
308            && let Some(base) = global.get("defaultTools").and_then(Value::as_array)
309        {
310            let mut combined = base.clone();
311            combined.extend(entries.iter().cloned());
312            project["defaultTools"] = Value::Array(combined);
313        }
314        deep_merge(&mut global, project);
315    }
316    let mut settings: Settings = serde_json::from_value(global).unwrap_or_default();
317    // Subagents are a user-level authority choice. A repository must not turn
318    // them on through a trusted project settings file.
319    settings.subagents.enabled = subagents_enabled;
320    // Workflows spend the user's tokens many agents at a time, so the same rule
321    // applies: only the user's own settings file decides whether they are on.
322    settings.workflows.enabled = workflows_enabled;
323    settings.cache_warming = cache_warming;
324    settings.voice_backend = voice_backend;
325    settings
326}
327
328impl Settings {
329    /// Read only user settings when creating the installation ID.
330    pub fn get_or_create_device_id() -> anyhow::Result<String> {
331        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!(
332            "ChatGPT login has no user settings path. A home directory is required. Set HOME and run login again."
333        ))?;
334        let mut settings: Value = match std::fs::read_to_string(&path) {
335            Ok(text) => serde_json::from_str(&text).map_err(|_| anyhow::anyhow!(
336                "ChatGPT login cannot read {}. Settings must contain a JSON object. Correct the JSON and run login again.", path.display()
337            ))?,
338            Err(error) if error.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
339            Err(error) => return Err(error.into()),
340        };
341        if !settings.is_object() {
342            anyhow::bail!(
343                "ChatGPT login cannot update {}. Settings must contain a JSON object. Correct the JSON and run login again.",
344                path.display()
345            );
346        }
347        if let Some(id) = settings.get("deviceId").and_then(Value::as_str)
348            && uuid::Uuid::parse_str(id).is_ok()
349        {
350            return Ok(id.to_string());
351        }
352        let id = uuid::Uuid::new_v4().to_string();
353        settings["deviceId"] = Value::String(id.clone());
354        if let Some(parent) = path.parent() {
355            std::fs::create_dir_all(parent)?;
356        }
357        std::fs::write(path, serde_json::to_string_pretty(&settings)?)?;
358        Ok(id)
359    }
360
361    pub fn auto_recap_enabled(&self) -> bool {
362        self.auto_recap.unwrap_or(true)
363    }
364
365    /// Load global settings, overlaying project settings when trusted.
366    pub fn load(cwd: &Path, project_trusted: bool) -> Settings {
367        let global = global_settings_path()
368            .and_then(|p| read_json(&p))
369            .unwrap_or_else(|| Value::Object(Default::default()));
370        let project = project_trusted
371            .then(|| read_json(&project_settings_path(cwd)))
372            .flatten();
373        merged_settings(global, project)
374    }
375
376    pub fn save_global(&self) -> anyhow::Result<()> {
377        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!("no home directory"))?;
378        if let Some(parent) = path.parent() {
379            std::fs::create_dir_all(parent)?;
380        }
381        let mut value = serde_json::to_value(self)?;
382        if let Some(id) = read_json(&path).and_then(|disk| disk.get("deviceId").cloned()) {
383            value["deviceId"] = id;
384        }
385        std::fs::write(&path, serde_json::to_string_pretty(&value)?)?;
386        Ok(())
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use serde_json::json;
394
395    #[test]
396    fn project_tool_modifiers_preserve_user_selection() {
397        let user = json!({"defaultTools":["read", "+grep"]});
398        let settings =
399            merged_settings(user.clone(), Some(json!({"defaultTools":["-read", "+ls"]})));
400        assert_eq!(
401            settings.default_tools.unwrap(),
402            ["read", "+grep", "-read", "+ls"]
403        );
404        let settings = merged_settings(user.clone(), Some(json!({"defaultTools":["bash"]})));
405        assert_eq!(settings.default_tools.unwrap(), ["bash"]);
406        let settings = merged_settings(user, Some(json!({"defaultTools":[]})));
407        assert_eq!(settings.default_tools.unwrap(), ["read", "+grep"]);
408        let settings = merged_settings(
409            json!({"deviceId":"user-id"}),
410            Some(json!({"deviceId":"project-id"})),
411        );
412        assert_eq!(settings.extra["deviceId"], "user-id");
413    }
414
415    #[test]
416    fn project_cannot_enable_cloud_voice() {
417        let settings = merged_settings(json!({}), Some(json!({"voiceBackend":"deepgram"})));
418        assert_eq!(settings.voice_backend, None);
419        let settings = merged_settings(
420            json!({"voiceBackend":"elevenlabs"}),
421            Some(json!({"voiceBackend":"local"})),
422        );
423        assert_eq!(settings.voice_backend.as_deref(), Some("elevenlabs"));
424    }
425
426    #[test]
427    fn deep_merge_nested() {
428        let mut base =
429            json!({"theme": "dark", "compaction": {"enabled": true, "reserveTokens": 16384}});
430        deep_merge(&mut base, json!({"compaction": {"reserveTokens": 8192}}));
431        assert_eq!(base["theme"], "dark");
432        assert_eq!(base["compaction"]["enabled"], true);
433        assert_eq!(base["compaction"]["reserveTokens"], 8192);
434    }
435
436    #[test]
437    fn defaults_match_pi() {
438        let s = Settings::default();
439        assert_eq!(s.compaction.mode, CompactionMode::Summary);
440        assert_eq!(s.compaction.reserve_tokens, 16_384);
441        assert_eq!(s.compaction.keep_recent_tokens, 20_000);
442        assert_eq!(s.reasoning_effort.mode, ReasoningEffortMode::Fixed);
443        assert_eq!(s.retry.max_retries, 3);
444        assert_eq!(s.retry.max_agent_delay_ms, 60_000);
445        assert_eq!(s.cache_warming, CacheWarmingMode::Streaming);
446        assert!(!s.subagents.enabled);
447        assert_eq!(s.steering_mode, QueueMode::OneAtATime);
448        assert!(s.auto_recap_enabled());
449        assert_eq!(s.markdown.mermaid, MermaidRendering::Streaming);
450    }
451
452    #[test]
453    fn compaction_model_overrides_use_exact_provider_model_keys() {
454        let settings: Settings = serde_json::from_value(json!({
455            "compaction": {
456                "modelOverrides": {
457                    "anthropic/claude-opus-4-8": {
458                        "reserveTokens": 1000,
459                        "keepRecentTokens": 2000
460                    }
461                }
462            },
463            "cacheWarming": "idle"
464        }))
465        .unwrap();
466        let override_settings = &settings.compaction.model_overrides["anthropic/claude-opus-4-8"];
467        assert_eq!(override_settings.reserve_tokens, Some(1000));
468        assert_eq!(override_settings.keep_recent_tokens, Some(2000));
469        assert_eq!(settings.cache_warming, CacheWarmingMode::Idle);
470    }
471
472    #[test]
473    fn old_compaction_settings_default_to_summary_mode() {
474        let settings: Settings = serde_json::from_value(json!({
475            "compaction": {"enabled": true, "reserveTokens": 1, "keepRecentTokens": 2}
476        }))
477        .unwrap();
478        assert_eq!(settings.compaction.mode, CompactionMode::Summary);
479        assert_eq!(
480            serde_json::to_value(settings).unwrap()["compaction"]["mode"],
481            "summary"
482        );
483    }
484
485    #[test]
486    fn old_settings_default_to_fixed_reasoning_effort() {
487        let settings: Settings = serde_json::from_value(json!({})).unwrap();
488        assert_eq!(settings.reasoning_effort.mode, ReasoningEffortMode::Fixed);
489        assert_eq!(
490            serde_json::to_value(settings).unwrap()["reasoningEffort"]["mode"],
491            "fixed"
492        );
493    }
494
495    #[test]
496    fn queue_mode_wire_format() {
497        assert_eq!(
498            serde_json::to_value(QueueMode::OneAtATime).unwrap(),
499            "one-at-a-time"
500        );
501    }
502
503    #[test]
504    fn markdown_settings_use_the_pi_wire_shape() {
505        let value = serde_json::to_value(Settings::default()).unwrap();
506        assert_eq!(value["markdown"]["mermaid"], "streaming");
507        assert!(value.get("mermaidRendering").is_none());
508    }
509
510    #[test]
511    fn webmcp_settings_use_the_reference_wire_shape() {
512        let loaded: Settings = serde_json::from_value(json!({
513            "webmcp": {
514                "allowedOrigins": ["example.com"],
515                "disallowedOrigins": ["blocked.example"],
516                "cdp": 9333
517            }
518        }))
519        .unwrap();
520        assert_eq!(
521            loaded.webmcp.allowed_origins,
522            Some(vec!["example.com".to_string()])
523        );
524        assert_eq!(
525            loaded.webmcp.disallowed_origins,
526            vec!["blocked.example".to_string()]
527        );
528        assert_eq!(loaded.webmcp.cdp, Some(json!(9333)));
529    }
530
531    #[test]
532    fn subagents_are_opt_in_on_the_wire() {
533        let value = serde_json::to_value(Settings::default()).unwrap();
534        assert_eq!(value["subagents"]["enabled"], false);
535
536        let loaded: Settings = serde_json::from_value(json!({})).unwrap();
537        assert!(!loaded.subagents.enabled);
538    }
539
540    #[test]
541    fn workflows_default_on_but_only_matter_with_subagents() {
542        let settings = Settings::default();
543        assert!(settings.workflows.enabled);
544        assert!(settings.workflows.confirm);
545        assert!(settings.workflows.keyword_trigger);
546        assert_eq!(settings.workflows.size, WorkflowSize::Medium);
547        // Subagents stay off, so workflows are unavailable until the user opts
548        // in to child agents.
549        assert!(!settings.subagents.enabled);
550    }
551
552    #[test]
553    fn project_settings_cannot_turn_workflows_on_or_off() {
554        let off = merged_settings(
555            json!({"workflows": {"enabled": false}}),
556            Some(json!({"workflows": {"enabled": true}})),
557        );
558        assert!(!off.workflows.enabled);
559
560        let on = merged_settings(json!({}), Some(json!({"workflows": {"enabled": false}})));
561        assert!(on.workflows.enabled);
562    }
563
564    #[test]
565    fn project_settings_cannot_enable_paid_cache_warming() {
566        let settings = merged_settings(
567            json!({"cacheWarming": "off"}),
568            Some(json!({"cacheWarming": "idle"})),
569        );
570        assert_eq!(settings.cache_warming, CacheWarmingMode::Off);
571    }
572
573    #[test]
574    fn the_size_guideline_maps_to_an_agent_count() {
575        assert_eq!(WorkflowSize::Small.target_agents(), Some(5));
576        assert_eq!(WorkflowSize::Medium.target_agents(), Some(15));
577        assert_eq!(WorkflowSize::Large.target_agents(), Some(50));
578        assert_eq!(WorkflowSize::Unrestricted.target_agents(), None);
579    }
580
581    #[test]
582    fn project_settings_cannot_change_subagent_authority() {
583        let enabled = merged_settings(
584            json!({"subagents": {"enabled": true}}),
585            Some(json!({"subagents": {"enabled": false}})),
586        );
587        assert!(enabled.subagents.enabled);
588
589        let disabled = merged_settings(json!({}), Some(json!({"subagents": {"enabled": true}})));
590        assert!(!disabled.subagents.enabled);
591    }
592}