use clap::{Parser, Subcommand};
pub(crate) const DEFAULT_MAX_RETRIES: usize = 10;
pub(crate) const DEFAULT_RATE_LIMIT_RETRIES: usize = 5;
pub(crate) const PLAN_STDIN_SENTINEL: &str = "stdin";
#[derive(Parser, Debug)]
#[command(
name = "cruise",
version,
about = "YAML-driven coding agent workflow orchestrator",
args_conflicts_with_subcommands = true
)]
pub struct Cli {
#[arg(long, value_name = "INPUT", conflicts_with = "input")]
pub plan: Option<String>,
#[arg(long)]
pub skip_planning: bool,
#[arg(long, value_name = "OWNER/REPO")]
pub repo: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(conflicts_with = "plan")]
pub input: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Plan(PlanArgs),
#[command(hide = true)]
PlanWorker(PlanWorkerArgs),
Draft(DraftArgs),
Run(RunArgs),
List(ListArgs),
Clean(CleanArgs),
Config(ConfigArgs),
Exec(ExecArgs),
}
#[derive(Parser, Debug)]
#[expect(
clippy::struct_excessive_bools,
reason = "CLI flags are naturally boolean"
)]
pub struct PlanArgs {
pub input: Option<String>,
#[arg(short = 'c', long)]
pub config: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub skip_planning: bool,
#[arg(long, conflicts_with = "skip_planning")]
pub grill: bool,
#[arg(long, conflicts_with = "grill")]
pub no_interactive_planning: bool,
#[arg(long, value_name = "OWNER/REPO")]
pub repo: Option<String>,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
}
#[derive(Parser, Debug)]
pub struct DraftArgs {
pub input: Option<String>,
#[arg(short = 'c', long)]
pub config: Option<String>,
}
#[derive(Parser, Debug)]
pub struct PlanWorkerArgs {
#[arg(long)]
pub session: String,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
}
#[derive(Parser, Debug)]
pub struct RunArgs {
#[arg(conflicts_with = "all")]
pub session: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long, default_value_t = DEFAULT_MAX_RETRIES)]
pub max_retries: usize,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Parser, Debug)]
pub struct CleanArgs {}
#[derive(Parser, Debug)]
pub struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ConfigArgs {
#[arg(long, value_name = "N")]
pub set_parallelism: Option<usize>,
}
#[derive(Parser, Debug)]
pub struct ExecArgs {
pub input: Option<String>,
#[arg(short = 'c', long)]
pub config: Option<String>,
#[arg(long, default_value_t = DEFAULT_MAX_RETRIES)]
pub max_retries: usize,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
#[arg(long)]
pub dry_run: bool,
}
pub fn parse_cli() -> Cli {
let mut cli = Cli::parse();
if cli.command.is_none()
&& cli.plan.is_none()
&& cli.input.is_none()
&& !std::io::IsTerminal::is_terminal(&std::io::stdin())
{
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).ok();
let trimmed = input.trim().to_string();
if !trimmed.is_empty() {
cli.input = Some(trimmed);
}
}
cli
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn test_cli_verify() {
Cli::command().debug_assert();
}
#[test]
fn test_plan_subcommand_with_input() {
let cli = Cli::parse_from(["cruise", "plan", "add feature X"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert_eq!(args.input, Some("add feature X".to_string()));
assert!(!args.dry_run);
assert_eq!(args.rate_limit_retries, DEFAULT_RATE_LIMIT_RETRIES);
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_subcommand_with_config() {
let cli = Cli::parse_from(["cruise", "plan", "-c", "my.yaml", "task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert_eq!(args.config, Some("my.yaml".to_string()));
assert_eq!(args.input, Some("task".to_string()));
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_subcommand_dry_run() {
let cli = Cli::parse_from(["cruise", "plan", "--dry-run", "task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert!(args.dry_run);
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_grill_defaults_to_false() {
let cli = Cli::parse_from(["cruise", "plan", "task"]);
match cli.command {
Some(Commands::Plan(args)) => assert!(!args.grill),
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_grill_flag_sets_true() {
let cli = Cli::parse_from(["cruise", "plan", "--grill", "task"]);
match cli.command {
Some(Commands::Plan(args)) => assert!(args.grill),
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_no_interactive_planning_defaults_to_false() {
let cli = Cli::parse_from(["cruise", "plan", "task"]);
match cli.command {
Some(Commands::Plan(args)) => assert!(!args.no_interactive_planning),
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_no_interactive_planning_flag_sets_true() {
let cli = Cli::parse_from(["cruise", "plan", "--no-interactive-planning", "task"]);
match cli.command {
Some(Commands::Plan(args)) => assert!(args.no_interactive_planning),
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_no_interactive_planning_conflicts_with_grill() {
let result = Cli::try_parse_from([
"cruise",
"plan",
"--no-interactive-planning",
"--grill",
"task",
]);
assert!(result.is_err());
}
#[test]
fn test_plan_grill_conflicts_with_skip_planning() {
let result = Cli::try_parse_from(["cruise", "plan", "--grill", "--skip-planning", "task"]);
assert!(result.is_err());
}
#[test]
fn test_run_subcommand_defaults() {
let cli = Cli::parse_from(["cruise", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.session, None);
assert_eq!(args.max_retries, DEFAULT_MAX_RETRIES);
assert_eq!(args.rate_limit_retries, DEFAULT_RATE_LIMIT_RETRIES);
assert!(!args.dry_run);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_with_session() {
let cli = Cli::parse_from(["cruise", "run", "20260306143000"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.session, Some("20260306143000".to_string()));
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_flags() {
let cli = Cli::parse_from([
"cruise",
"run",
"--max-retries",
"20",
"--rate-limit-retries",
"3",
]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.max_retries, 20);
assert_eq!(args.rate_limit_retries, 3);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_root_plan_flag_with_inline_input_parses() {
let cli = Cli::try_parse_from(["cruise", "--plan", "add feature X"])
.unwrap_or_else(|e| panic!("expected --plan to parse successfully: {e}"));
assert!(cli.command.is_none(), "expected no subcommand: {cli:?}");
assert_eq!(cli.plan, Some("add feature X".to_string()));
assert_eq!(cli.input, None, "legacy positional input should stay empty");
}
#[test]
fn test_root_plan_flag_with_stdin_literal_parses() {
let cli = Cli::try_parse_from(["cruise", "--plan", "stdin"])
.unwrap_or_else(|e| panic!("expected --plan stdin to parse successfully: {e}"));
assert!(cli.command.is_none(), "expected no subcommand: {cli:?}");
assert_eq!(cli.plan, Some(PLAN_STDIN_SENTINEL.to_string()));
assert_eq!(cli.input, None, "legacy positional input should stay empty");
}
#[test]
fn test_list_subcommand() {
let cli = Cli::parse_from(["cruise", "list"]);
assert!(matches!(cli.command, Some(Commands::List(_))));
}
#[test]
fn test_list_subcommand_json_flag_defaults_to_false() {
let cli = Cli::parse_from(["cruise", "list"]);
match cli.command {
Some(Commands::List(args)) => {
assert!(!args.json, "--json should default to false");
}
_ => panic!("expected List subcommand"),
}
}
#[test]
fn test_list_subcommand_json_flag_is_true_with_flag() {
let cli = Cli::parse_from(["cruise", "list", "--json"]);
match cli.command {
Some(Commands::List(args)) => {
assert!(args.json, "--json should be true");
}
_ => panic!("expected List subcommand"),
}
}
#[test]
fn test_clean_subcommand_default() {
let cli = Cli::parse_from(["cruise", "clean"]);
assert!(matches!(cli.command, Some(Commands::Clean(_))));
}
#[test]
fn test_backward_compat_no_subcommand() {
let cli = Cli::parse_from(["cruise", "add hello world"]);
assert!(cli.command.is_none());
assert_eq!(cli.input, Some("add hello world".to_string()));
}
#[test]
fn test_no_args() {
let cli = Cli::parse_from(["cruise"]);
assert!(cli.command.is_none());
assert_eq!(cli.input, None);
}
#[test]
fn test_run_subcommand_all_flag() {
let cli = Cli::parse_from(["cruise", "run", "--all"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.all, "--all should be true");
assert_eq!(args.session, None);
assert!(!args.dry_run);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_all_flag_default_is_false() {
let cli = Cli::parse_from(["cruise", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(!args.all, "--all should default to false");
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_all_with_dry_run() {
let cli = Cli::parse_from(["cruise", "run", "--all", "--dry-run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.all);
assert!(args.dry_run);
assert_eq!(args.session, None);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_config_subcommand_no_flags_shows_current_config() {
let cli = Cli::parse_from(["cruise", "config"]);
match cli.command {
Some(Commands::Config(args)) => {
assert_eq!(
args.set_parallelism, None,
"no flags means show-only mode (set_parallelism is None)"
);
}
_ => panic!("expected Config subcommand"),
}
}
#[test]
fn test_config_subcommand_set_parallelism_parses_value() {
let cli = Cli::parse_from(["cruise", "config", "--set-parallelism", "4"]);
match cli.command {
Some(Commands::Config(args)) => {
assert_eq!(
args.set_parallelism,
Some(4),
"expected set_parallelism = Some(4)"
);
}
_ => panic!("expected Config subcommand"),
}
}
#[test]
fn test_config_subcommand_set_parallelism_one() {
let cli = Cli::parse_from(["cruise", "config", "--set-parallelism", "1"]);
match cli.command {
Some(Commands::Config(args)) => {
assert_eq!(args.set_parallelism, Some(1));
}
_ => panic!("expected Config subcommand"),
}
}
#[test]
fn test_config_subcommand_is_registered_in_cli_verify() {
Cli::command().debug_assert();
}
#[test]
fn test_exec_subcommand_with_input_and_config() {
let cli = Cli::parse_from(["cruise", "exec", "-c", "my.yaml", "task"]);
match cli.command {
Some(Commands::Exec(args)) => {
assert_eq!(args.input, Some("task".to_string()));
assert_eq!(args.config, Some("my.yaml".to_string()));
}
_ => panic!("expected Exec subcommand"),
}
}
#[test]
fn test_exec_subcommand_defaults() {
let cli = Cli::parse_from(["cruise", "exec"]);
match cli.command {
Some(Commands::Exec(args)) => {
assert_eq!(args.input, None);
assert_eq!(args.config, None);
assert_eq!(args.max_retries, DEFAULT_MAX_RETRIES);
assert_eq!(args.rate_limit_retries, DEFAULT_RATE_LIMIT_RETRIES);
assert!(!args.dry_run);
}
_ => panic!("expected Exec subcommand"),
}
}
#[test]
fn test_exec_subcommand_dry_run_flag() {
let cli = Cli::parse_from(["cruise", "exec", "--dry-run"]);
match cli.command {
Some(Commands::Exec(args)) => {
assert!(args.dry_run);
assert_eq!(args.input, None);
}
_ => panic!("expected Exec subcommand"),
}
}
#[test]
fn test_exec_subcommand_custom_retries() {
let cli = Cli::parse_from([
"cruise",
"exec",
"--max-retries",
"5",
"--rate-limit-retries",
"2",
]);
match cli.command {
Some(Commands::Exec(args)) => {
assert_eq!(args.max_retries, 5);
assert_eq!(args.rate_limit_retries, 2);
}
_ => panic!("expected Exec subcommand"),
}
}
#[test]
fn test_plan_skip_planning_defaults_to_false() {
let cli = Cli::parse_from(["cruise", "plan", "my task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert!(
!args.skip_planning,
"--skip-planning should default to false"
);
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_skip_planning_flag_sets_true() {
let cli = Cli::parse_from(["cruise", "plan", "--skip-planning", "my task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert!(
args.skip_planning,
"--skip-planning should be true when flag is present"
);
assert_eq!(args.input, Some("my task".to_string()));
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_skip_planning_combined_with_config() {
let cli = Cli::parse_from([
"cruise",
"plan",
"--skip-planning",
"-c",
"my.yaml",
"do task",
]);
match cli.command {
Some(Commands::Plan(args)) => {
assert!(args.skip_planning);
assert_eq!(args.config, Some("my.yaml".to_string()));
assert_eq!(args.input, Some("do task".to_string()));
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_root_skip_planning_defaults_to_false() {
let cli = Cli::try_parse_from(["cruise", "--plan", "task"])
.unwrap_or_else(|e| panic!("parse error: {e}"));
assert!(
!cli.skip_planning,
"root-level --skip-planning should default to false"
);
}
#[test]
fn test_root_skip_planning_with_plan_flag() {
let cli = Cli::try_parse_from(["cruise", "--plan", "task text", "--skip-planning"])
.unwrap_or_else(|e| panic!("expected --plan --skip-planning to parse: {e}"));
assert_eq!(cli.plan, Some("task text".to_string()));
assert!(
cli.skip_planning,
"root-level --skip-planning should be true"
);
}
#[test]
fn test_root_skip_planning_with_positional_input_parses() {
let cli = Cli::try_parse_from(["cruise", "--skip-planning", "task text"])
.unwrap_or_else(|e| panic!("expected --skip-planning [INPUT] to parse: {e}"));
assert!(cli.command.is_none(), "expected no subcommand: {cli:?}");
assert!(
cli.skip_planning,
"root-level --skip-planning should be true"
);
assert_eq!(cli.input, Some("task text".to_string()));
assert_eq!(cli.plan, None, "--plan should stay empty");
}
#[test]
fn test_plan_repo_flag_parses() {
let cli = Cli::parse_from(["cruise", "plan", "--repo", "owner/repo", "my task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert_eq!(args.repo, Some("owner/repo".to_string()));
assert_eq!(args.input, Some("my task".to_string()));
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_repo_defaults_to_none() {
let cli = Cli::parse_from(["cruise", "plan", "my task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert_eq!(args.repo, None, "--repo should default to None");
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_root_plan_flag_with_repo_parses() {
let cli = Cli::try_parse_from(["cruise", "--plan", "task", "--repo", "owner/repo"])
.unwrap_or_else(|e| panic!("expected --plan --repo to parse: {e}"));
assert_eq!(cli.plan, Some("task".to_string()));
assert_eq!(cli.repo, Some("owner/repo".to_string()));
}
#[test]
fn test_root_skip_planning_alone_parses() {
let cli = Cli::try_parse_from(["cruise", "--skip-planning"])
.unwrap_or_else(|e| panic!("expected bare --skip-planning to parse: {e}"));
assert!(cli.skip_planning);
assert_eq!(cli.input, None);
assert_eq!(cli.plan, None);
}
}