capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
//! Structured user settings (`~/.capo/agent/settings.json`).
//!
//! JSON to match pi's `~/.pi/agent/settings.json`. Sections beyond the
//! Phase 0 baseline (e.g. `ui.theme` variants, `logging` rotation) are
//! reserved for M4+ and intentionally minimal here.

mod cli;
mod load;

pub use cli::CliOverrides;
pub use load::{load, load_with};

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings {
    #[serde(default)]
    pub model: ModelSettings,
    #[serde(default)]
    pub anthropic: AnthropicSettings,
    #[serde(default)]
    pub ui: UiSettings,
    #[serde(default)]
    pub session: SessionSettings,
    #[serde(default)]
    pub logging: LoggingSettings,
}

impl Settings {
    pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
        load::load(cli)
    }

    pub fn load_with<F>(
        agent_dir: &std::path::Path,
        cli: &CliOverrides,
        env_lookup: F,
    ) -> crate::Result<Self>
    where
        F: Fn(&str) -> Option<String>,
    {
        load::load_with(agent_dir, cli, env_lookup)
    }
}

/// Supported LLM providers. `Settings::model.provider` is a string for
/// JSON-friendliness; this enum is the parsed form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmProviderKind {
    /// Direct HTTPS call to `api.anthropic.com`. Requires an API key
    /// (env `ANTHROPIC_API_KEY` or `auth.json::anthropic.key`).
    Anthropic,
    /// Shells out to the locally-installed `claude` binary
    /// (Claude Code CLI). The CLI handles its own authentication;
    /// capo's `Auth` is not consulted. Requires `claude` on `$PATH`.
    ClaudeCode,
    /// Shells out to the locally-installed `codex` binary
    /// (OpenAI Codex CLI). The CLI handles its own authentication;
    /// capo's `Auth` is not consulted. Requires `codex` on `$PATH`.
    CodexCli,
    /// Direct HTTPS call to OpenAI's API. Requires an API key
    /// (env `OPENAI_API_KEY` or `auth.json::openai.key`).
    Openai,
    /// Direct HTTPS call to Google's Gemini REST API. Requires an API key
    /// (env `GEMINI_API_KEY` or `auth.json::gemini.key`).
    Gemini,
    /// Shells out to the locally-installed `gemini` binary (Google's
    /// Gemini CLI). The CLI handles its own authentication; capo's `Auth`
    /// is not consulted. Requires `gemini` on `$PATH`.
    GeminiCli,
}

impl LlmProviderKind {
    /// Parse a `Settings::model.provider` string. Case-insensitive.
    pub fn parse(s: &str) -> Result<Self, String> {
        match s.trim().to_ascii_lowercase().as_str() {
            "anthropic" => Ok(Self::Anthropic),
            "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
            "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
            "openai" => Ok(Self::Openai),
            "gemini" => Ok(Self::Gemini),
            "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
            other => Err(format!(
                "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
            )),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelSettings {
    /// Provider tag. One of: `"anthropic"`, `"claude-code"`, `"codex-cli"`,
    /// `"openai"`, `"gemini"`, `"gemini-cli"`.
    /// See [`LlmProviderKind`] for semantics. Validated at LLM construction
    /// time (not at settings deserialize) so a forgotten provider only
    /// blocks the user when they actually launch capo.
    pub provider: String,
    pub name: String,
    pub max_tokens: u32,
}

impl Default for ModelSettings {
    fn default() -> Self {
        Self {
            provider: "anthropic".to_string(),
            name: "claude-sonnet-4-6".to_string(),
            max_tokens: 8192,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AnthropicSettings {
    /// Base URL for the Anthropic HTTP API. Defaults to the public endpoint;
    /// override via `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
    /// `settings.json` to route through a corporate proxy or test server.
    /// Only consulted when `model.provider == "anthropic"` (CLI providers
    /// shell out to their own binaries and ignore this).
    pub base_url: String,
}

impl Default for AnthropicSettings {
    fn default() -> Self {
        Self {
            base_url: "https://api.anthropic.com".to_string(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UiSettings {
    pub theme: String,
    pub streaming_throttle_ms: u32,
    pub footer_show_cost: bool,
}

impl Default for UiSettings {
    fn default() -> Self {
        Self {
            theme: "dark".to_string(),
            streaming_throttle_ms: 50,
            footer_show_cost: true,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionSettings {
    pub autosave: bool,
    /// Fraction of context window (0.0..=1.0) at which autocompact triggers.
    /// Name keeps `_pct` for spec/JSON-config compatibility, but the value is
    /// always interpreted as a 0.0–1.0 fraction. `0.0` disables autocompact.
    pub compact_at_context_pct: f32,
    /// Total LLM context window in tokens. Used by `AutocompactExtension`
    /// to compute the absolute compaction threshold. Default 200_000
    /// matches motosan-agent-loop's `AutocompactConfig::default`. **Override
    /// for newer models** (Sonnet 4.5+ supports 1_000_000).
    pub max_context_tokens: usize,
    /// Number of recent user-turn boundaries kept verbatim during compaction.
    pub keep_turns: usize,
}

impl Default for SessionSettings {
    fn default() -> Self {
        Self {
            autosave: true,
            compact_at_context_pct: 0.85,
            max_context_tokens: 1_000_000,
            keep_turns: 3,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoggingSettings {
    pub level: String,
    pub file: String,
}

impl Default for LoggingSettings {
    fn default() -> Self {
        Self {
            level: "info".to_string(),
            file: "~/.capo/agent/capo.log".to_string(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn settings_default_matches_documented_baseline() {
        let s = Settings::default();
        assert_eq!(s.model.provider, "anthropic");
        assert_eq!(s.model.name, "claude-sonnet-4-6");
        assert_eq!(s.model.max_tokens, 8192);
        assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
        assert_eq!(s.ui.theme, "dark");
        assert_eq!(s.ui.streaming_throttle_ms, 50);
        assert!(s.ui.footer_show_cost);
        assert!(s.session.autosave);
        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
        assert_eq!(s.session.max_context_tokens, 1_000_000);
        assert_eq!(s.session.keep_turns, 3);
        assert_eq!(s.logging.level, "info");
        assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
    }

    #[test]
    fn settings_serde_round_trips() {
        let original = Settings::default();
        let json = match serde_json::to_string_pretty(&original) {
            Ok(json) => json,
            Err(err) => panic!("serialize failed: {err}"),
        };
        let back: Settings = match serde_json::from_str(&json) {
            Ok(settings) => settings,
            Err(err) => panic!("deserialize failed: {err}"),
        };
        assert_eq!(original, back);
    }

    #[test]
    fn settings_loads_partial_json_with_defaults_for_missing_sections() {
        let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
        let s: Settings = match serde_json::from_str(json) {
            Ok(settings) => settings,
            Err(err) => panic!("deserialize failed: {err}"),
        };
        assert_eq!(s.model.name, "x");
        // Missing sections fall back to defaults.
        assert_eq!(s.ui.theme, "dark");
        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
    }

    fn parse_ok(input: &str) -> LlmProviderKind {
        match LlmProviderKind::parse(input) {
            Ok(kind) => kind,
            Err(err) => panic!("parse failed for {input}: {err}"),
        }
    }

    fn parse_err(input: &str) -> String {
        match LlmProviderKind::parse(input) {
            Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
            Err(err) => err,
        }
    }

    #[test]
    fn llm_provider_kind_parses_all_three() {
        assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
        assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
        assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
        assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
        assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
    }

    #[test]
    fn llm_provider_kind_rejects_unknown() {
        let err = parse_err("gpt-4");
        assert!(err.contains("unknown LLM provider"));
        assert!(err.contains("gpt-4"));
        assert!(err.contains("anthropic"));
    }

    #[test]
    fn parse_recognizes_v0_5_providers() {
        assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
        assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
        assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
        assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
    }

    #[test]
    fn parse_unknown_provider_lists_all_six_supported_names() {
        let err = parse_err("nope");
        for name in [
            "anthropic",
            "claude-code",
            "codex-cli",
            "openai",
            "gemini",
            "gemini-cli",
        ] {
            assert!(err.contains(name), "{name} missing from: {err}");
        }
    }
}