use std::path::PathBuf;
use clap::Parser;
#[derive(Debug, Parser)]
#[command(name = "capo", version, about = "Capo — a Rust coding agent")]
pub struct Cli {
#[arg(short = 'p', long = "prompt")]
pub prompt: Option<String>,
#[arg(
long = "image",
value_name = "PATH",
action = clap::ArgAction::Append,
requires = "prompt",
conflicts_with = "rpc",
)]
pub images: Vec<PathBuf>,
#[arg(long)]
pub model: Option<String>,
#[arg(long = "provider")]
pub provider: Option<String>,
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
pub verbosity: u8,
#[arg(short = 'n', long = "no-context-files")]
pub no_context_files: bool,
#[arg(long = "no-skills")]
pub no_skills: bool,
#[arg(short = 'c', long = "continue")]
pub continue_session: bool,
#[arg(long = "session")]
pub session: Option<String>,
#[arg(short = 'r', long = "resume")]
pub resume: bool,
#[arg(long = "json", requires = "prompt")]
pub json: bool,
#[arg(long = "dangerously-allow-all")]
pub dangerously_allow_all: bool,
#[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));
}
}