Skip to main content

capo_agent/settings/
mod.rs

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