Skip to main content

capo_agent/settings/
mod.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3//! Structured user settings (`~/.capo/agent/settings.toml`).
4//!
5//! TOML is the current on-disk format for capo's editable settings. The
6//! loader still accepts legacy `settings.json` when no TOML file exists.
7//! Sections beyond the Phase 0 baseline (e.g. `ui.theme` variants,
8//! `logging` rotation) are reserved for M4+ and intentionally minimal here.
9
10mod cli;
11mod load;
12
13pub use cli::CliOverrides;
14pub use load::{load, load_with};
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
19pub struct Settings {
20    #[serde(default)]
21    pub model: ModelSettings,
22    #[serde(default)]
23    pub anthropic: AnthropicSettings,
24    #[serde(default)]
25    pub ui: UiSettings,
26    #[serde(default)]
27    pub session: SessionSettings,
28    #[serde(default)]
29    pub logging: LoggingSettings,
30    /// v0.10 Phase A: permission mode picker default. Top-level
31    /// `[permissions]` section in settings.toml. See spec §3.
32    #[serde(default)]
33    pub permissions: PermissionsSettings,
34}
35
36impl Settings {
37    pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
38        load::load(cli)
39    }
40
41    pub fn load_with<F>(
42        agent_dir: &std::path::Path,
43        cli: &CliOverrides,
44        env_lookup: F,
45    ) -> crate::Result<Self>
46    where
47        F: Fn(&str) -> Option<String>,
48    {
49        load::load_with(agent_dir, cli, env_lookup)
50    }
51}
52
53/// Supported LLM providers. `Settings::model.provider` is a string for
54/// JSON-friendliness; this enum is the parsed form.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum LlmProviderKind {
57    /// Direct HTTPS call to `api.anthropic.com`. Requires an API key
58    /// (env `ANTHROPIC_API_KEY` or `auth.json::anthropic.key`).
59    Anthropic,
60    /// Shells out to the locally-installed `claude` binary
61    /// (Claude Code CLI). The CLI handles its own authentication;
62    /// capo's `Auth` is not consulted. Requires `claude` on `$PATH`.
63    ClaudeCode,
64    /// Shells out to the locally-installed `codex` binary
65    /// (OpenAI Codex CLI). The CLI handles its own authentication;
66    /// capo's `Auth` is not consulted. Requires `codex` on `$PATH`.
67    CodexCli,
68    /// Direct HTTPS call to OpenAI's API. Requires an API key
69    /// (env `OPENAI_API_KEY` or `auth.json::openai.key`).
70    Openai,
71    /// Direct HTTPS call to Google's Gemini REST API. Requires an API key
72    /// (env `GEMINI_API_KEY` or `auth.json::gemini.key`).
73    Gemini,
74    /// Shells out to the locally-installed `gemini` binary (Google's
75    /// Gemini CLI). The CLI handles its own authentication; capo's `Auth`
76    /// is not consulted. Requires `gemini` on `$PATH`.
77    GeminiCli,
78}
79
80impl LlmProviderKind {
81    /// Parse a `Settings::model.provider` string. Case-insensitive.
82    pub fn parse(s: &str) -> Result<Self, String> {
83        match s.trim().to_ascii_lowercase().as_str() {
84            "anthropic" => Ok(Self::Anthropic),
85            "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
86            "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
87            "openai" => Ok(Self::Openai),
88            "gemini" => Ok(Self::Gemini),
89            "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
90            other => Err(format!(
91                "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
92            )),
93        }
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct ModelSettings {
99    /// Provider tag. One of: `"anthropic"`, `"claude-code"`, `"codex-cli"`,
100    /// `"openai"`, `"gemini"`, `"gemini-cli"`.
101    /// See [`LlmProviderKind`] for semantics. Validated at LLM construction
102    /// time (not at settings deserialize) so a forgotten provider only
103    /// blocks the user when they actually launch capo.
104    pub provider: String,
105    pub name: String,
106    pub max_tokens: u32,
107}
108
109impl Default for ModelSettings {
110    fn default() -> Self {
111        Self {
112            provider: "anthropic".to_string(),
113            name: "claude-sonnet-4-6".to_string(),
114            max_tokens: 8192,
115        }
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120pub struct AnthropicSettings {
121    /// Base URL for the Anthropic HTTP API. Defaults to the public endpoint;
122    /// override via `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
123    /// `settings.json` to route through a corporate proxy or test server.
124    /// Only consulted when `model.provider == "anthropic"` (CLI providers
125    /// shell out to their own binaries and ignore this).
126    pub base_url: String,
127    /// Extended-thinking budget in tokens. `None` (default) disables extended
128    /// thinking entirely — Anthropic returns only a regular assistant text
129    /// block and no `thinking_delta` SSE events. `Some(N)` enables extended
130    /// thinking with a budget of `N` tokens (Anthropic-recommended range:
131    /// 1024-32000). When set, `CoreEvent::ThinkingChunk`/`ThinkingDone` fire
132    /// during streaming and capo renders the v0.11.0 live thinking block.
133    ///
134    /// To actually see the live block in the TUI, also set
135    /// `ui.hide_thinking_blocks = false` (which defaults to `true`).
136    ///
137    /// Only consulted when `model.provider == "anthropic"`. Non-Anthropic
138    /// providers (OpenAI / Gemini / CLI providers) silently ignore this.
139    #[serde(default)]
140    pub thinking_budget_tokens: Option<u32>,
141}
142
143impl Default for AnthropicSettings {
144    fn default() -> Self {
145        Self {
146            base_url: "https://api.anthropic.com".to_string(),
147            thinking_budget_tokens: None,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153pub struct UiSettings {
154    pub theme: String,
155    pub streaming_throttle_ms: u32,
156    pub footer_show_cost: bool,
157    /// v0.9 Phase A: hide MessageBlock::Thinking entirely from rendering.
158    /// Default true — most users don't want to read the model's reasoning.
159    #[serde(default = "default_hide_thinking_blocks")]
160    pub hide_thinking_blocks: bool,
161    /// v0.9 Phase A: collapse ToolResult output to "tool ✓ (N lines)" 1-liner.
162    /// Default true — keeps transcript scannable.
163    #[serde(default = "default_collapse_tool_output_by_default")]
164    pub collapse_tool_output_by_default: bool,
165    /// v0.9 Phase A: collapse tool-call args when pretty-printed > N lines.
166    /// 0 disables. Default 2.
167    #[serde(default = "default_collapse_tool_args_max_lines")]
168    pub collapse_tool_args_max_lines: u32,
169    /// v0.9 Phase A: collapse assistant messages older than N from the end.
170    /// 0 disables (opt-in). Default 0.
171    #[serde(default = "default_collapse_history_after")]
172    pub collapse_history_after: u32,
173}
174
175fn default_hide_thinking_blocks() -> bool {
176    true
177}
178
179fn default_collapse_tool_output_by_default() -> bool {
180    true
181}
182
183fn default_collapse_tool_args_max_lines() -> u32 {
184    2
185}
186
187fn default_collapse_history_after() -> u32 {
188    0
189}
190
191impl Default for UiSettings {
192    fn default() -> Self {
193        Self {
194            theme: "dark".to_string(),
195            streaming_throttle_ms: 50,
196            footer_show_cost: true,
197            hide_thinking_blocks: default_hide_thinking_blocks(),
198            collapse_tool_output_by_default: default_collapse_tool_output_by_default(),
199            collapse_tool_args_max_lines: default_collapse_tool_args_max_lines(),
200            collapse_history_after: default_collapse_history_after(),
201        }
202    }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206pub struct SessionSettings {
207    pub autosave: bool,
208    /// Fraction of context window (0.0..=1.0) at which autocompact triggers.
209    /// Name keeps `_pct` for spec/JSON-config compatibility, but the value is
210    /// always interpreted as a 0.0–1.0 fraction. `0.0` disables autocompact.
211    pub compact_at_context_pct: f32,
212    /// Total LLM context window in tokens. Used by `AutocompactExtension`
213    /// to compute the absolute compaction threshold. Default 200_000
214    /// matches motosan-agent-loop's `AutocompactConfig::default`. **Override
215    /// for newer models** (Sonnet 4.5+ supports 1_000_000).
216    pub max_context_tokens: usize,
217    /// Number of recent user-turn boundaries kept verbatim during compaction.
218    pub keep_turns: usize,
219}
220
221impl Default for SessionSettings {
222    fn default() -> Self {
223        Self {
224            autosave: true,
225            compact_at_context_pct: 0.85,
226            max_context_tokens: 1_000_000,
227            keep_turns: 3,
228        }
229    }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct PermissionsSettings {
234    /// Permission mode for new sessions. One of `"bypass"`, `"accept-edits"`,
235    /// `"prompt"`. Mismatched values fall through to `Settings::default()`
236    /// handling (the binary surfaces a parse error at load time, then
237    /// falls back to the type-level default — `"prompt"`).
238    ///
239    /// Edit via `/settings` or directly via `~/.capo/agent/settings.toml`.
240    ///
241    /// **NOT to be confused with** `~/.capo/agent/permissions.toml` (a
242    /// separate file holding the `[bash]`/`[write]`/`[edit]` allowlist —
243    /// covered by the existing `Policy` loader). The `[permissions]`
244    /// section here ONLY carries `default_mode`.
245    #[serde(default = "default_permission_mode_str")]
246    pub default_mode: String,
247}
248
249impl Default for PermissionsSettings {
250    fn default() -> Self {
251        Self {
252            default_mode: default_permission_mode_str(),
253        }
254    }
255}
256
257fn default_permission_mode_str() -> String {
258    "prompt".to_string()
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262pub struct LoggingSettings {
263    pub level: String,
264    pub file: String,
265}
266
267impl Default for LoggingSettings {
268    fn default() -> Self {
269        Self {
270            level: "info".to_string(),
271            file: "~/.capo/agent/capo.log".to_string(),
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn settings_default_matches_documented_baseline() {
282        let s = Settings::default();
283        assert_eq!(s.model.provider, "anthropic");
284        assert_eq!(s.model.name, "claude-sonnet-4-6");
285        assert_eq!(s.model.max_tokens, 8192);
286        assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
287        assert_eq!(s.anthropic.thinking_budget_tokens, None);
288        assert_eq!(s.ui.theme, "dark");
289        assert_eq!(s.ui.streaming_throttle_ms, 50);
290        assert!(s.ui.footer_show_cost);
291        assert!(s.session.autosave);
292        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
293        assert_eq!(s.session.max_context_tokens, 1_000_000);
294        assert_eq!(s.session.keep_turns, 3);
295        assert_eq!(s.logging.level, "info");
296        assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
297    }
298
299    #[test]
300    fn settings_serde_round_trips() {
301        let original = Settings::default();
302        let json = match serde_json::to_string_pretty(&original) {
303            Ok(json) => json,
304            Err(err) => panic!("serialize failed: {err}"),
305        };
306        let back: Settings = match serde_json::from_str(&json) {
307            Ok(settings) => settings,
308            Err(err) => panic!("deserialize failed: {err}"),
309        };
310        assert_eq!(original, back);
311    }
312
313    #[test]
314    fn settings_loads_partial_json_with_defaults_for_missing_sections() {
315        let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
316        let s: Settings = match serde_json::from_str(json) {
317            Ok(settings) => settings,
318            Err(err) => panic!("deserialize failed: {err}"),
319        };
320        assert_eq!(s.model.name, "x");
321        // Missing sections fall back to defaults.
322        assert_eq!(s.ui.theme, "dark");
323        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
324    }
325
326    #[test]
327    fn settings_v0_10_permissions_default_mode_defaults_to_prompt() {
328        let s = Settings::default();
329        assert_eq!(s.permissions.default_mode, "prompt");
330    }
331
332    #[test]
333    fn settings_v0_10_permissions_section_round_trips() {
334        let mut s = Settings::default();
335        s.permissions.default_mode = "bypass".into();
336        let toml_str = toml::to_string(&s).expect("serialize");
337        assert!(toml_str.contains("default_mode = \"bypass\""));
338        let back: Settings = toml::from_str(&toml_str).expect("deserialize");
339        assert_eq!(back.permissions.default_mode, "bypass");
340    }
341
342    #[test]
343    fn settings_v0_10_missing_permissions_section_uses_default() {
344        // Existing v0.9 settings.toml has no [permissions] — must still load.
345        let toml_str = r#"
346[model]
347provider = "anthropic"
348name = "claude-opus-4-7"
349max_tokens = 8192
350"#;
351        let s: Settings = toml::from_str(toml_str).expect("load partial");
352        assert_eq!(s.permissions.default_mode, "prompt");
353    }
354
355    #[test]
356    fn ui_settings_v0_9_defaults() {
357        let s = UiSettings::default();
358        assert!(
359            s.hide_thinking_blocks,
360            "hide_thinking_blocks should default to true"
361        );
362        assert!(
363            s.collapse_tool_output_by_default,
364            "collapse_tool_output_by_default should default to true"
365        );
366        assert_eq!(s.collapse_tool_args_max_lines, 2);
367        assert_eq!(s.collapse_history_after, 0);
368    }
369
370    #[test]
371    fn ui_settings_v0_9_round_trips_through_toml() {
372        let mut s = Settings::default();
373        s.ui.hide_thinking_blocks = false;
374        s.ui.collapse_tool_output_by_default = false;
375        s.ui.collapse_tool_args_max_lines = 5;
376        s.ui.collapse_history_after = 10;
377        let toml_str = toml::to_string(&s).expect("serialize");
378        // Smoke: payload contains the new fields.
379        assert!(toml_str.contains("hide_thinking_blocks = false"));
380        assert!(toml_str.contains("collapse_tool_output_by_default = false"));
381        assert!(toml_str.contains("collapse_tool_args_max_lines = 5"));
382        assert!(toml_str.contains("collapse_history_after = 10"));
383        let back: Settings = toml::from_str(&toml_str).expect("deserialize");
384        assert!(!back.ui.hide_thinking_blocks);
385        assert!(!back.ui.collapse_tool_output_by_default);
386        assert_eq!(back.ui.collapse_tool_args_max_lines, 5);
387        assert_eq!(back.ui.collapse_history_after, 10);
388    }
389
390    fn parse_ok(input: &str) -> LlmProviderKind {
391        match LlmProviderKind::parse(input) {
392            Ok(kind) => kind,
393            Err(err) => panic!("parse failed for {input}: {err}"),
394        }
395    }
396
397    fn parse_err(input: &str) -> String {
398        match LlmProviderKind::parse(input) {
399            Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
400            Err(err) => err,
401        }
402    }
403
404    #[test]
405    fn llm_provider_kind_parses_all_three() {
406        assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
407        assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
408        assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
409        assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
410        assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
411    }
412
413    #[test]
414    fn llm_provider_kind_rejects_unknown() {
415        let err = parse_err("gpt-4");
416        assert!(err.contains("unknown LLM provider"));
417        assert!(err.contains("gpt-4"));
418        assert!(err.contains("anthropic"));
419    }
420
421    #[test]
422    fn parse_recognizes_v0_5_providers() {
423        assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
424        assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
425        assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
426        assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
427    }
428
429    #[test]
430    fn parse_unknown_provider_lists_all_six_supported_names() {
431        let err = parse_err("nope");
432        for name in [
433            "anthropic",
434            "claude-code",
435            "codex-cli",
436            "openai",
437            "gemini",
438            "gemini-cli",
439        ] {
440            assert!(err.contains(name), "{name} missing from: {err}");
441        }
442    }
443}