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}
31
32impl Settings {
33    pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
34        load::load(cli)
35    }
36
37    pub fn load_with<F>(
38        agent_dir: &std::path::Path,
39        cli: &CliOverrides,
40        env_lookup: F,
41    ) -> crate::Result<Self>
42    where
43        F: Fn(&str) -> Option<String>,
44    {
45        load::load_with(agent_dir, cli, env_lookup)
46    }
47}
48
49/// Supported LLM providers. `Settings::model.provider` is a string for
50/// JSON-friendliness; this enum is the parsed form.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum LlmProviderKind {
53    /// Direct HTTPS call to `api.anthropic.com`. Requires an API key
54    /// (env `ANTHROPIC_API_KEY` or `auth.json::anthropic.key`).
55    Anthropic,
56    /// Shells out to the locally-installed `claude` binary
57    /// (Claude Code CLI). The CLI handles its own authentication;
58    /// capo's `Auth` is not consulted. Requires `claude` on `$PATH`.
59    ClaudeCode,
60    /// Shells out to the locally-installed `codex` binary
61    /// (OpenAI Codex CLI). The CLI handles its own authentication;
62    /// capo's `Auth` is not consulted. Requires `codex` on `$PATH`.
63    CodexCli,
64    /// Direct HTTPS call to OpenAI's API. Requires an API key
65    /// (env `OPENAI_API_KEY` or `auth.json::openai.key`).
66    Openai,
67    /// Direct HTTPS call to Google's Gemini REST API. Requires an API key
68    /// (env `GEMINI_API_KEY` or `auth.json::gemini.key`).
69    Gemini,
70    /// Shells out to the locally-installed `gemini` binary (Google's
71    /// Gemini CLI). The CLI handles its own authentication; capo's `Auth`
72    /// is not consulted. Requires `gemini` on `$PATH`.
73    GeminiCli,
74}
75
76impl LlmProviderKind {
77    /// Parse a `Settings::model.provider` string. Case-insensitive.
78    pub fn parse(s: &str) -> Result<Self, String> {
79        match s.trim().to_ascii_lowercase().as_str() {
80            "anthropic" => Ok(Self::Anthropic),
81            "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
82            "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
83            "openai" => Ok(Self::Openai),
84            "gemini" => Ok(Self::Gemini),
85            "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
86            other => Err(format!(
87                "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
88            )),
89        }
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct ModelSettings {
95    /// Provider tag. One of: `"anthropic"`, `"claude-code"`, `"codex-cli"`,
96    /// `"openai"`, `"gemini"`, `"gemini-cli"`.
97    /// See [`LlmProviderKind`] for semantics. Validated at LLM construction
98    /// time (not at settings deserialize) so a forgotten provider only
99    /// blocks the user when they actually launch capo.
100    pub provider: String,
101    pub name: String,
102    pub max_tokens: u32,
103}
104
105impl Default for ModelSettings {
106    fn default() -> Self {
107        Self {
108            provider: "anthropic".to_string(),
109            name: "claude-sonnet-4-6".to_string(),
110            max_tokens: 8192,
111        }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct AnthropicSettings {
117    /// Base URL for the Anthropic HTTP API. Defaults to the public endpoint;
118    /// override via `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
119    /// `settings.json` to route through a corporate proxy or test server.
120    /// Only consulted when `model.provider == "anthropic"` (CLI providers
121    /// shell out to their own binaries and ignore this).
122    pub base_url: String,
123}
124
125impl Default for AnthropicSettings {
126    fn default() -> Self {
127        Self {
128            base_url: "https://api.anthropic.com".to_string(),
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct UiSettings {
135    pub theme: String,
136    pub streaming_throttle_ms: u32,
137    pub footer_show_cost: bool,
138    /// v0.9 Phase A: hide MessageBlock::Thinking entirely from rendering.
139    /// Default true — most users don't want to read the model's reasoning.
140    #[serde(default = "default_hide_thinking_blocks")]
141    pub hide_thinking_blocks: bool,
142    /// v0.9 Phase A: collapse ToolResult output to "tool ✓ (N lines)" 1-liner.
143    /// Default true — keeps transcript scannable.
144    #[serde(default = "default_collapse_tool_output_by_default")]
145    pub collapse_tool_output_by_default: bool,
146    /// v0.9 Phase A: collapse tool-call args when pretty-printed > N lines.
147    /// 0 disables. Default 2.
148    #[serde(default = "default_collapse_tool_args_max_lines")]
149    pub collapse_tool_args_max_lines: u32,
150    /// v0.9 Phase A: collapse assistant messages older than N from the end.
151    /// 0 disables (opt-in). Default 0.
152    #[serde(default = "default_collapse_history_after")]
153    pub collapse_history_after: u32,
154}
155
156fn default_hide_thinking_blocks() -> bool {
157    true
158}
159
160fn default_collapse_tool_output_by_default() -> bool {
161    true
162}
163
164fn default_collapse_tool_args_max_lines() -> u32 {
165    2
166}
167
168fn default_collapse_history_after() -> u32 {
169    0
170}
171
172impl Default for UiSettings {
173    fn default() -> Self {
174        Self {
175            theme: "dark".to_string(),
176            streaming_throttle_ms: 50,
177            footer_show_cost: true,
178            hide_thinking_blocks: default_hide_thinking_blocks(),
179            collapse_tool_output_by_default: default_collapse_tool_output_by_default(),
180            collapse_tool_args_max_lines: default_collapse_tool_args_max_lines(),
181            collapse_history_after: default_collapse_history_after(),
182        }
183    }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187pub struct SessionSettings {
188    pub autosave: bool,
189    /// Fraction of context window (0.0..=1.0) at which autocompact triggers.
190    /// Name keeps `_pct` for spec/JSON-config compatibility, but the value is
191    /// always interpreted as a 0.0–1.0 fraction. `0.0` disables autocompact.
192    pub compact_at_context_pct: f32,
193    /// Total LLM context window in tokens. Used by `AutocompactExtension`
194    /// to compute the absolute compaction threshold. Default 200_000
195    /// matches motosan-agent-loop's `AutocompactConfig::default`. **Override
196    /// for newer models** (Sonnet 4.5+ supports 1_000_000).
197    pub max_context_tokens: usize,
198    /// Number of recent user-turn boundaries kept verbatim during compaction.
199    pub keep_turns: usize,
200}
201
202impl Default for SessionSettings {
203    fn default() -> Self {
204        Self {
205            autosave: true,
206            compact_at_context_pct: 0.85,
207            max_context_tokens: 1_000_000,
208            keep_turns: 3,
209        }
210    }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214pub struct LoggingSettings {
215    pub level: String,
216    pub file: String,
217}
218
219impl Default for LoggingSettings {
220    fn default() -> Self {
221        Self {
222            level: "info".to_string(),
223            file: "~/.capo/agent/capo.log".to_string(),
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn settings_default_matches_documented_baseline() {
234        let s = Settings::default();
235        assert_eq!(s.model.provider, "anthropic");
236        assert_eq!(s.model.name, "claude-sonnet-4-6");
237        assert_eq!(s.model.max_tokens, 8192);
238        assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
239        assert_eq!(s.ui.theme, "dark");
240        assert_eq!(s.ui.streaming_throttle_ms, 50);
241        assert!(s.ui.footer_show_cost);
242        assert!(s.session.autosave);
243        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
244        assert_eq!(s.session.max_context_tokens, 1_000_000);
245        assert_eq!(s.session.keep_turns, 3);
246        assert_eq!(s.logging.level, "info");
247        assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
248    }
249
250    #[test]
251    fn settings_serde_round_trips() {
252        let original = Settings::default();
253        let json = match serde_json::to_string_pretty(&original) {
254            Ok(json) => json,
255            Err(err) => panic!("serialize failed: {err}"),
256        };
257        let back: Settings = match serde_json::from_str(&json) {
258            Ok(settings) => settings,
259            Err(err) => panic!("deserialize failed: {err}"),
260        };
261        assert_eq!(original, back);
262    }
263
264    #[test]
265    fn settings_loads_partial_json_with_defaults_for_missing_sections() {
266        let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
267        let s: Settings = match serde_json::from_str(json) {
268            Ok(settings) => settings,
269            Err(err) => panic!("deserialize failed: {err}"),
270        };
271        assert_eq!(s.model.name, "x");
272        // Missing sections fall back to defaults.
273        assert_eq!(s.ui.theme, "dark");
274        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
275    }
276
277    #[test]
278    fn ui_settings_v0_9_defaults() {
279        let s = UiSettings::default();
280        assert!(
281            s.hide_thinking_blocks,
282            "hide_thinking_blocks should default to true"
283        );
284        assert!(
285            s.collapse_tool_output_by_default,
286            "collapse_tool_output_by_default should default to true"
287        );
288        assert_eq!(s.collapse_tool_args_max_lines, 2);
289        assert_eq!(s.collapse_history_after, 0);
290    }
291
292    #[test]
293    fn ui_settings_v0_9_round_trips_through_toml() {
294        let mut s = Settings::default();
295        s.ui.hide_thinking_blocks = false;
296        s.ui.collapse_tool_output_by_default = false;
297        s.ui.collapse_tool_args_max_lines = 5;
298        s.ui.collapse_history_after = 10;
299        let toml_str = toml::to_string(&s).expect("serialize");
300        // Smoke: payload contains the new fields.
301        assert!(toml_str.contains("hide_thinking_blocks = false"));
302        assert!(toml_str.contains("collapse_tool_output_by_default = false"));
303        assert!(toml_str.contains("collapse_tool_args_max_lines = 5"));
304        assert!(toml_str.contains("collapse_history_after = 10"));
305        let back: Settings = toml::from_str(&toml_str).expect("deserialize");
306        assert!(!back.ui.hide_thinking_blocks);
307        assert!(!back.ui.collapse_tool_output_by_default);
308        assert_eq!(back.ui.collapse_tool_args_max_lines, 5);
309        assert_eq!(back.ui.collapse_history_after, 10);
310    }
311
312    fn parse_ok(input: &str) -> LlmProviderKind {
313        match LlmProviderKind::parse(input) {
314            Ok(kind) => kind,
315            Err(err) => panic!("parse failed for {input}: {err}"),
316        }
317    }
318
319    fn parse_err(input: &str) -> String {
320        match LlmProviderKind::parse(input) {
321            Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
322            Err(err) => err,
323        }
324    }
325
326    #[test]
327    fn llm_provider_kind_parses_all_three() {
328        assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
329        assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
330        assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
331        assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
332        assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
333    }
334
335    #[test]
336    fn llm_provider_kind_rejects_unknown() {
337        let err = parse_err("gpt-4");
338        assert!(err.contains("unknown LLM provider"));
339        assert!(err.contains("gpt-4"));
340        assert!(err.contains("anthropic"));
341    }
342
343    #[test]
344    fn parse_recognizes_v0_5_providers() {
345        assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
346        assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
347        assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
348        assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
349    }
350
351    #[test]
352    fn parse_unknown_provider_lists_all_six_supported_names() {
353        let err = parse_err("nope");
354        for name in [
355            "anthropic",
356            "claude-code",
357            "codex-cli",
358            "openai",
359            "gemini",
360            "gemini-cli",
361        ] {
362            assert!(err.contains(name), "{name} missing from: {err}");
363        }
364    }
365}