Skip to main content

capo_agent/settings/
mod.rs

1//! Structured user settings (`~/.capo/agent/settings.toml`).
2//!
3//! TOML is the current on-disk format for capo's editable settings. The
4//! loader still accepts legacy `settings.json` when no TOML file exists.
5//! Sections beyond the Phase 0 baseline (e.g. `ui.theme` variants,
6//! `logging` rotation) are reserved for M4+ and intentionally minimal here.
7
8mod cli;
9mod load;
10
11pub use cli::CliOverrides;
12pub use load::{load, load_with};
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17pub struct Settings {
18    #[serde(default)]
19    pub model: ModelSettings,
20    #[serde(default)]
21    pub anthropic: AnthropicSettings,
22    #[serde(default)]
23    pub ui: UiSettings,
24    #[serde(default)]
25    pub session: SessionSettings,
26    #[serde(default)]
27    pub logging: LoggingSettings,
28}
29
30impl Settings {
31    pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
32        load::load(cli)
33    }
34
35    pub fn load_with<F>(
36        agent_dir: &std::path::Path,
37        cli: &CliOverrides,
38        env_lookup: F,
39    ) -> crate::Result<Self>
40    where
41        F: Fn(&str) -> Option<String>,
42    {
43        load::load_with(agent_dir, cli, env_lookup)
44    }
45}
46
47/// Supported LLM providers. `Settings::model.provider` is a string for
48/// JSON-friendliness; this enum is the parsed form.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LlmProviderKind {
51    /// Direct HTTPS call to `api.anthropic.com`. Requires an API key
52    /// (env `ANTHROPIC_API_KEY` or `auth.json::anthropic.key`).
53    Anthropic,
54    /// Shells out to the locally-installed `claude` binary
55    /// (Claude Code CLI). The CLI handles its own authentication;
56    /// capo's `Auth` is not consulted. Requires `claude` on `$PATH`.
57    ClaudeCode,
58    /// Shells out to the locally-installed `codex` binary
59    /// (OpenAI Codex CLI). The CLI handles its own authentication;
60    /// capo's `Auth` is not consulted. Requires `codex` on `$PATH`.
61    CodexCli,
62    /// Direct HTTPS call to OpenAI's API. Requires an API key
63    /// (env `OPENAI_API_KEY` or `auth.json::openai.key`).
64    Openai,
65    /// Direct HTTPS call to Google's Gemini REST API. Requires an API key
66    /// (env `GEMINI_API_KEY` or `auth.json::gemini.key`).
67    Gemini,
68    /// Shells out to the locally-installed `gemini` binary (Google's
69    /// Gemini CLI). The CLI handles its own authentication; capo's `Auth`
70    /// is not consulted. Requires `gemini` on `$PATH`.
71    GeminiCli,
72}
73
74impl LlmProviderKind {
75    /// Parse a `Settings::model.provider` string. Case-insensitive.
76    pub fn parse(s: &str) -> Result<Self, String> {
77        match s.trim().to_ascii_lowercase().as_str() {
78            "anthropic" => Ok(Self::Anthropic),
79            "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
80            "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
81            "openai" => Ok(Self::Openai),
82            "gemini" => Ok(Self::Gemini),
83            "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
84            other => Err(format!(
85                "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
86            )),
87        }
88    }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct ModelSettings {
93    /// Provider tag. One of: `"anthropic"`, `"claude-code"`, `"codex-cli"`,
94    /// `"openai"`, `"gemini"`, `"gemini-cli"`.
95    /// See [`LlmProviderKind`] for semantics. Validated at LLM construction
96    /// time (not at settings deserialize) so a forgotten provider only
97    /// blocks the user when they actually launch capo.
98    pub provider: String,
99    pub name: String,
100    pub max_tokens: u32,
101}
102
103impl Default for ModelSettings {
104    fn default() -> Self {
105        Self {
106            provider: "anthropic".to_string(),
107            name: "claude-sonnet-4-6".to_string(),
108            max_tokens: 8192,
109        }
110    }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
114pub struct AnthropicSettings {
115    /// Base URL for the Anthropic HTTP API. Defaults to the public endpoint;
116    /// override via `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
117    /// `settings.json` to route through a corporate proxy or test server.
118    /// Only consulted when `model.provider == "anthropic"` (CLI providers
119    /// shell out to their own binaries and ignore this).
120    pub base_url: String,
121}
122
123impl Default for AnthropicSettings {
124    fn default() -> Self {
125        Self {
126            base_url: "https://api.anthropic.com".to_string(),
127        }
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132pub struct UiSettings {
133    pub theme: String,
134    pub streaming_throttle_ms: u32,
135    pub footer_show_cost: bool,
136}
137
138impl Default for UiSettings {
139    fn default() -> Self {
140        Self {
141            theme: "dark".to_string(),
142            streaming_throttle_ms: 50,
143            footer_show_cost: true,
144        }
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct SessionSettings {
150    pub autosave: bool,
151    /// Fraction of context window (0.0..=1.0) at which autocompact triggers.
152    /// Name keeps `_pct` for spec/JSON-config compatibility, but the value is
153    /// always interpreted as a 0.0–1.0 fraction. `0.0` disables autocompact.
154    pub compact_at_context_pct: f32,
155    /// Total LLM context window in tokens. Used by `AutocompactExtension`
156    /// to compute the absolute compaction threshold. Default 200_000
157    /// matches motosan-agent-loop's `AutocompactConfig::default`. **Override
158    /// for newer models** (Sonnet 4.5+ supports 1_000_000).
159    pub max_context_tokens: usize,
160    /// Number of recent user-turn boundaries kept verbatim during compaction.
161    pub keep_turns: usize,
162}
163
164impl Default for SessionSettings {
165    fn default() -> Self {
166        Self {
167            autosave: true,
168            compact_at_context_pct: 0.85,
169            max_context_tokens: 1_000_000,
170            keep_turns: 3,
171        }
172    }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub struct LoggingSettings {
177    pub level: String,
178    pub file: String,
179}
180
181impl Default for LoggingSettings {
182    fn default() -> Self {
183        Self {
184            level: "info".to_string(),
185            file: "~/.capo/agent/capo.log".to_string(),
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn settings_default_matches_documented_baseline() {
196        let s = Settings::default();
197        assert_eq!(s.model.provider, "anthropic");
198        assert_eq!(s.model.name, "claude-sonnet-4-6");
199        assert_eq!(s.model.max_tokens, 8192);
200        assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
201        assert_eq!(s.ui.theme, "dark");
202        assert_eq!(s.ui.streaming_throttle_ms, 50);
203        assert!(s.ui.footer_show_cost);
204        assert!(s.session.autosave);
205        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
206        assert_eq!(s.session.max_context_tokens, 1_000_000);
207        assert_eq!(s.session.keep_turns, 3);
208        assert_eq!(s.logging.level, "info");
209        assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
210    }
211
212    #[test]
213    fn settings_serde_round_trips() {
214        let original = Settings::default();
215        let json = match serde_json::to_string_pretty(&original) {
216            Ok(json) => json,
217            Err(err) => panic!("serialize failed: {err}"),
218        };
219        let back: Settings = match serde_json::from_str(&json) {
220            Ok(settings) => settings,
221            Err(err) => panic!("deserialize failed: {err}"),
222        };
223        assert_eq!(original, back);
224    }
225
226    #[test]
227    fn settings_loads_partial_json_with_defaults_for_missing_sections() {
228        let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
229        let s: Settings = match serde_json::from_str(json) {
230            Ok(settings) => settings,
231            Err(err) => panic!("deserialize failed: {err}"),
232        };
233        assert_eq!(s.model.name, "x");
234        // Missing sections fall back to defaults.
235        assert_eq!(s.ui.theme, "dark");
236        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
237    }
238
239    fn parse_ok(input: &str) -> LlmProviderKind {
240        match LlmProviderKind::parse(input) {
241            Ok(kind) => kind,
242            Err(err) => panic!("parse failed for {input}: {err}"),
243        }
244    }
245
246    fn parse_err(input: &str) -> String {
247        match LlmProviderKind::parse(input) {
248            Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
249            Err(err) => err,
250        }
251    }
252
253    #[test]
254    fn llm_provider_kind_parses_all_three() {
255        assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
256        assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
257        assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
258        assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
259        assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
260    }
261
262    #[test]
263    fn llm_provider_kind_rejects_unknown() {
264        let err = parse_err("gpt-4");
265        assert!(err.contains("unknown LLM provider"));
266        assert!(err.contains("gpt-4"));
267        assert!(err.contains("anthropic"));
268    }
269
270    #[test]
271    fn parse_recognizes_v0_5_providers() {
272        assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
273        assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
274        assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
275        assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
276    }
277
278    #[test]
279    fn parse_unknown_provider_lists_all_six_supported_names() {
280        let err = parse_err("nope");
281        for name in [
282            "anthropic",
283            "claude-code",
284            "codex-cli",
285            "openai",
286            "gemini",
287            "gemini-cli",
288        ] {
289            assert!(err.contains(name), "{name} missing from: {err}");
290        }
291    }
292}