capo-cli 0.6.0

Capo — a Rust-native coding agent CLI.
use std::path::PathBuf;

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>,

    /// Attach an image to the prompt. Repeatable. Requires `-p`. Forbidden
    /// with `--rpc` — RPC clients attach via `Command::SendUserMessage`.
    /// Supported formats: PNG, JPEG, GIF, WEBP. Max 5 MiB per image.
    #[arg(
        long = "image",
        value_name = "PATH",
        action = clap::ArgAction::Append,
        requires = "prompt",
        conflicts_with = "rpc",
    )]
    pub images: Vec<PathBuf>,

    /// 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, openai, gemini, gemini-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/json.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,
}

#[derive(Debug)]
pub enum Mode {
    Print(capo_agent::UserMessage),
    Json(capo_agent::UserMessage),
    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(self.user_message(p)),
            (Some(p), false, false) => Mode::Print(self.user_message(p)),
            (None, _, false) => Mode::InteractiveStub,
        }
    }

    fn user_message(&self, prompt: &str) -> capo_agent::UserMessage {
        capo_agent::UserMessage {
            text: prompt.to_string(),
            attachments: self
                .images
                .iter()
                .map(|p| capo_agent::Attachment::Image { path: p.clone() })
                .collect(),
        }
    }
}

#[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(msg) if msg.text == "hello" && msg.attachments.is_empty())
        );
    }

    #[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(msg) if msg.text == "hi" && msg.attachments.is_empty())
        );
        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(msg) if msg.text == "hi" && msg.attachments.is_empty())
        );
    }

    #[test]
    fn image_flag_repeats() {
        let cli = Cli::parse_from([
            "capo",
            "-p",
            "what",
            "--image",
            "/tmp/a.png",
            "--image",
            "/tmp/b.jpg",
        ]);
        assert_eq!(
            cli.images,
            vec![
                std::path::PathBuf::from("/tmp/a.png"),
                std::path::PathBuf::from("/tmp/b.jpg"),
            ]
        );
    }

    #[test]
    fn image_flag_requires_prompt() {
        let r = Cli::try_parse_from(["capo", "--image", "/tmp/a.png"]);
        assert!(r.is_err(), "--image without -p must be a usage error");
    }

    #[test]
    fn image_flag_conflicts_with_rpc() {
        let r = Cli::try_parse_from(["capo", "--rpc", "--image", "/tmp/a.png"]);
        assert!(r.is_err(), "--image must conflict with --rpc");
    }

    #[test]
    fn print_mode_carries_images_in_user_message() {
        let cli = Cli::parse_from(["capo", "-p", "describe", "--image", "/tmp/x.png"]);
        match cli.mode() {
            Mode::Print(msg) => {
                assert_eq!(msg.text, "describe");
                assert_eq!(msg.attachments.len(), 1);
                assert!(matches!(
                    &msg.attachments[0],
                    capo_agent::Attachment::Image { path } if path == std::path::Path::new("/tmp/x.png")
                ));
            }
            other => panic!("expected Print mode; got {other:?}"),
        }
    }

    #[test]
    fn json_mode_carries_images_in_user_message() {
        let cli = Cli::parse_from(["capo", "-p", "describe", "--json", "--image", "/tmp/x.png"]);
        match cli.mode() {
            Mode::Json(msg) => {
                assert_eq!(msg.text, "describe");
                assert_eq!(msg.attachments.len(), 1);
            }
            other => panic!("expected Json mode; got {other:?}"),
        }
    }

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