capo-cli 0.4.0

Capo — a Rust-native coding agent CLI.
use clap::Parser;

#[derive(Debug, Parser)]
#[command(name = "capo", version, about = "Capo — a Rust coding agent")]
pub struct Cli {
    /// Non-interactive print mode: send a single prompt, print the reply.
    #[arg(short = 'p', long = "prompt")]
    pub prompt: Option<String>,

    /// Override the configured model name.
    #[arg(long)]
    pub model: Option<String>,

    /// Override the configured LLM provider for this run.
    /// One of: anthropic, claude-code, codex-cli.
    #[arg(long = "provider")]
    pub provider: Option<String>,

    /// Increase log verbosity (-v = debug, -vv = trace). RUST_LOG always wins.
    #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
    pub verbosity: u8,

    /// Disable AGENTS.md / CLAUDE.md discovery.
    #[arg(short = 'n', long = "no-context-files")]
    pub no_context_files: bool,

    /// Disable skill discovery (`~/.capo/agent/skills/` + `<project>/.capo/skills/`).
    #[arg(long = "no-skills")]
    pub no_skills: bool,

    /// Continue the most recent session in this working directory.
    #[arg(short = 'c', long = "continue")]
    pub continue_session: bool,

    /// Resume a specific session by ULID prefix or absolute/expanded path.
    #[arg(long = "session")]
    pub session: Option<String>,

    /// Open the interactive session picker on startup.
    #[arg(short = 'r', long = "resume")]
    pub resume: bool,

    /// Emit the run as line-delimited JSON events instead of plain text.
    /// Requires `-p`. See docs/superpowers/specs/2026-05-19-capo-v0.4-design.md.
    #[arg(long = "json", requires = "prompt")]
    pub json: bool,

    /// In `--json` mode, allow every tool call without permission checks.
    /// Dangerous — intended only for trusted automation.
    #[arg(long = "dangerously-allow-all")]
    pub dangerously_allow_all: bool,

    /// Run as a long-lived JSON-RPC server speaking line-delimited JSON
    /// over stdin/stdout. Mutually exclusive with `-p`.
    #[arg(long = "rpc", conflicts_with = "prompt")]
    pub rpc: bool,
}

pub enum Mode {
    Print(String),
    Json(String),
    Rpc,
    InteractiveStub,
}

impl Cli {
    pub fn mode(&self) -> Mode {
        match (&self.prompt, self.json, self.rpc) {
            (_, _, true) => Mode::Rpc,
            (Some(p), true, false) => Mode::Json(p.clone()),
            (Some(p), false, false) => Mode::Print(p.clone()),
            (None, _, false) => Mode::InteractiveStub,
        }
    }
}

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

    #[test]
    fn print_mode_flag() {
        let cli = Cli::parse_from(["capo", "-p", "hello"]);
        assert!(matches!(cli.mode(), Mode::Print(s) if s == "hello"));
    }

    #[test]
    fn model_flag() {
        let cli = Cli::parse_from(["capo", "-p", "x", "--model", "claude-opus-4-7"]);
        assert_eq!(cli.model.as_deref(), Some("claude-opus-4-7"));
    }

    #[test]
    fn no_context_files_flag() {
        let cli = Cli::parse_from(["capo", "-p", "x", "-n"]);
        assert!(cli.no_context_files);
        let cli = Cli::parse_from(["capo", "-p", "x"]);
        assert!(!cli.no_context_files);
    }

    #[test]
    fn no_skills_flag() {
        let cli = Cli::parse_from(["capo", "--no-skills"]);
        assert!(cli.no_skills);
        let cli = Cli::parse_from(["capo"]);
        assert!(!cli.no_skills);
    }

    #[test]
    fn provider_flag_parses() {
        let cli = Cli::parse_from(["capo", "--provider", "claude-code"]);
        assert_eq!(cli.provider.as_deref(), Some("claude-code"));
    }

    #[test]
    fn session_flags_parse() {
        let cli = Cli::parse_from(["capo", "-c"]);
        assert!(cli.continue_session);

        let cli = Cli::parse_from(["capo", "--session", "01ABC"]);
        assert_eq!(cli.session.as_deref(), Some("01ABC"));

        let cli = Cli::parse_from(["capo", "-r"]);
        assert!(cli.resume);
    }

    #[test]
    fn print_mode_with_continue_flag_parses() {
        let cli = Cli::parse_from(["capo", "-p", "x", "-c"]);
        assert!(cli.continue_session);
        assert_eq!(cli.prompt.as_deref(), Some("x"));
    }

    #[test]
    fn json_flag_requires_prompt_and_yields_json_mode() {
        let cli = Cli::parse_from(["capo", "-p", "hi", "--json"]);
        assert!(matches!(cli.mode(), Mode::Json(p) if p == "hi"));
        assert!(!cli.dangerously_allow_all);
    }

    #[test]
    fn json_without_prompt_is_a_usage_error() {
        assert!(Cli::try_parse_from(["capo", "--json"]).is_err());
    }

    #[test]
    fn rpc_flag_yields_rpc_mode() {
        let cli = Cli::parse_from(["capo", "--rpc"]);
        assert!(matches!(cli.mode(), Mode::Rpc));
    }

    #[test]
    fn rpc_conflicts_with_prompt() {
        assert!(Cli::try_parse_from(["capo", "--rpc", "-p", "hi"]).is_err());
    }

    #[test]
    fn dangerously_allow_all_parses() {
        let cli = Cli::parse_from(["capo", "-p", "hi", "--json", "--dangerously-allow-all"]);
        assert!(cli.dangerously_allow_all);
    }

    #[test]
    fn prompt_without_json_is_still_print_mode() {
        let cli = Cli::parse_from(["capo", "-p", "hi"]);
        assert!(matches!(cli.mode(), Mode::Print(_)));
    }
}