capo-agent 0.10.1

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! v0.10: Permission mode picker — pi-style frictionless `bypass` /
//! Claude-Code-style `accept-edits` / current-default `prompt`.
//!
//! See spec §3 in `docs/superpowers/specs/2026-05-22-capo-v0.10-design.md`.

use serde::{Deserialize, Serialize};

/// Three permission modes for tool calls.
///
/// `bypass` auto-allows everything except hard-blocked paths
/// (`.git/**`, `.env*`, `**/.ssh/**`, etc — invariant across modes).
/// `accept-edits` auto-allows write/edit but prompts bash unless
/// allowlisted. `prompt` is the current capo default — every tool
/// prompts unless allowlisted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PermissionMode {
    Bypass,
    AcceptEdits,
    Prompt,
}

impl Default for PermissionMode {
    /// New installs default to `Prompt` (conservative). Pi-style users
    /// opt in via `settings.toml.permissions.default_mode = "bypass"`.
    fn default() -> Self {
        PermissionMode::Prompt
    }
}

impl std::str::FromStr for PermissionMode {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "bypass" => Ok(Self::Bypass),
            "accept-edits" => Ok(Self::AcceptEdits),
            "prompt" => Ok(Self::Prompt),
            other => Err(format!(
                "unknown mode: {other}; expected bypass | accept-edits | prompt"
            )),
        }
    }
}

impl std::fmt::Display for PermissionMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            Self::Bypass => "bypass",
            Self::AcceptEdits => "accept-edits",
            Self::Prompt => "prompt",
        };
        f.write_str(s)
    }
}

/// Cycle through the 3 modes "increasing danger" order: prompt → accept-edits → bypass → prompt.
/// Matches Claude Code's `Shift+Tab` convention.
pub fn next_mode(current: PermissionMode) -> PermissionMode {
    match current {
        PermissionMode::Prompt => PermissionMode::AcceptEdits,
        PermissionMode::AcceptEdits => PermissionMode::Bypass,
        PermissionMode::Bypass => PermissionMode::Prompt,
    }
}

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

    #[test]
    fn default_is_prompt() {
        assert_eq!(PermissionMode::default(), PermissionMode::Prompt);
    }

    #[test]
    fn next_mode_cycles_three_modes_in_order() {
        let mut m = PermissionMode::Prompt;
        m = next_mode(m);
        assert_eq!(m, PermissionMode::AcceptEdits);
        m = next_mode(m);
        assert_eq!(m, PermissionMode::Bypass);
        m = next_mode(m);
        assert_eq!(m, PermissionMode::Prompt);
    }

    #[test]
    fn from_str_round_trips_all_three() {
        for m in [
            PermissionMode::Bypass,
            PermissionMode::AcceptEdits,
            PermissionMode::Prompt,
        ] {
            let s = m.to_string();
            let back: PermissionMode = s.parse().expect("parse");
            assert_eq!(back, m, "round-trip failed for {s}");
        }
    }

    #[test]
    fn from_str_rejects_unknown() {
        let result: Result<PermissionMode, _> = "wibble".parse();
        assert!(result.is_err());
    }

    #[test]
    fn serde_round_trips_via_json() {
        let m = PermissionMode::AcceptEdits;
        let json = serde_json::to_string(&m).expect("serialize");
        assert_eq!(json, r#""accept-edits""#);
        let back: PermissionMode = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, m);
    }
}