use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "git-paw",
version,
about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
across git worktrees from a single terminal using tmux. Each branch gets its own \
worktree and AI session, running in parallel.",
after_help = "\x1b[1mQuick Start:\x1b[0m\n\n \
# Launch interactive session (picks CLI and branches)\n \
git paw\n\n \
# Use Claude on specific branches\n \
git paw start --cli claude --branches feat/auth,feat/api\n\n \
# Check session status\n \
git paw status\n\n \
# Stop session (preserves worktrees for later)\n \
git paw stop\n\n \
# Remove everything\n \
git paw purge"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(
about = "Launch a new session or reattach to an existing one",
long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
or launches a new interactive session.\n\n\
Examples:\n \
git paw start\n \
git paw start --cli claude\n \
git paw start --cli claude --branches feat/auth,feat/api\n \
git paw start --from-specs\n \
git paw start --from-specs --cli claude\n \
git paw start --dry-run\n \
git paw start --preset backend\n \
git paw start --supervisor # auto-approve safe prompts via [supervisor.auto_approve]"
)]
Start {
#[arg(long, help = "AI CLI to use (skips CLI picker)")]
cli: Option<String>,
#[arg(
long,
value_delimiter = ',',
help = "Comma-separated branches (skips branch picker)"
)]
branches: Option<Vec<String>>,
#[arg(
long,
help = "Launch from spec files (reads .git-paw/config.toml [specs])"
)]
from_specs: bool,
#[arg(long, help = "Preview the session plan without executing")]
dry_run: bool,
#[arg(long, help = "Use a named preset from config")]
preset: Option<String>,
#[arg(
long,
default_value_t = false,
help = "Enable supervisor mode for this session"
)]
supervisor: bool,
#[arg(long, help = "Bypass uncommitted-spec validation warning")]
force: bool,
},
#[command(
about = "Stop the session (kills tmux, keeps worktrees and state)",
long_about = "Kills the tmux session but preserves worktrees and session state on disk. \
Run `git paw start` later to recover the session.\n\n\
Example:\n git paw stop"
)]
Stop,
#[command(
about = "Remove everything (tmux session, worktrees, and state)",
long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
session state. Requires confirmation unless --force is used.\n\n\
Examples:\n git paw purge\n git paw purge --force"
)]
Purge {
#[arg(long, help = "Skip confirmation prompt")]
force: bool,
},
#[command(
about = "Show session state for the current repo",
long_about = "Displays the current session status, branches, CLIs, and worktree paths \
for the repository in the current directory.\n\n\
Example:\n git paw status"
)]
Status,
#[command(
about = "List detected and custom AI CLIs",
long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
registered in your config.\n\n\
Example:\n git paw list-clis"
)]
ListClis,
#[command(
about = "Register a custom AI CLI",
long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
The command can be an absolute path or a binary name on PATH.\n\n\
Examples:\n \
git paw add-cli my-agent /usr/local/bin/my-agent\n \
git paw add-cli my-agent my-agent --display-name \"My Agent\""
)]
AddCli {
#[arg(help = "Name to register the CLI as")]
name: String,
#[arg(help = "Command or path to the CLI binary")]
command: String,
#[arg(long, help = "Display name shown in prompts")]
display_name: Option<String>,
},
#[command(
about = "Unregister a custom AI CLI",
long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
removed — auto-detected CLIs cannot.\n\n\
Example:\n git paw remove-cli my-agent"
)]
RemoveCli {
#[arg(help = "Name of the custom CLI to remove")]
name: String,
},
#[command(
about = "Initialize .git-paw/ directory and configuration",
long_about = "Creates the .git-paw/ directory with a default config and sets up \
.gitignore for logs.\n\n\
Examples:\n git paw init"
)]
Init,
#[command(
hide = true,
name = "__dashboard",
about = "Internal: run the broker and dashboard in pane 0",
long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
in pane 0 of a tmux session. Not intended for direct invocation."
)]
Dashboard,
#[command(
about = "View captured session logs",
long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
for clean output. Use --color to view with colors via less -R.\n\n\
Examples:\n \
git paw replay --list\n \
git paw replay feat/add-auth\n \
git paw replay feat/add-auth --color\n \
git paw replay feat/add-auth --session paw-myproject"
)]
Replay {
#[arg(required_unless_present = "list", help = "Branch to replay")]
branch: Option<String>,
#[arg(long, help = "List available log sessions and branches")]
list: bool,
#[arg(long, help = "Display with colors via less -R")]
color: bool,
#[arg(long, help = "Session to replay from (defaults to most recent)")]
session: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> Cli {
let mut full = vec!["git-paw"];
full.extend(args);
Cli::try_parse_from(full).expect("failed to parse")
}
#[test]
fn no_args_defaults_to_none_command() {
let cli = parse(&[]);
assert!(
cli.command.is_none(),
"no args should yield None (handled as Start in main)"
);
}
#[test]
fn start_with_no_flags() {
let cli = parse(&["start"]);
match cli.command.unwrap() {
Command::Start {
cli,
branches,
from_specs,
dry_run,
preset,
supervisor,
force,
} => {
assert!(cli.is_none());
assert!(branches.is_none());
assert!(!from_specs);
assert!(!dry_run);
assert!(preset.is_none());
assert!(!supervisor);
assert!(!force);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_cli_flag() {
let cli = parse(&["start", "--cli", "claude"]);
match cli.command.unwrap() {
Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_branches_flag_comma_separated() {
let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
match cli.command.unwrap() {
Command::Start { branches, .. } => {
let b = branches.expect("branches should be set");
assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_dry_run() {
let cli = parse(&["start", "--dry-run"]);
match cli.command.unwrap() {
Command::Start { dry_run, .. } => assert!(dry_run),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_preset() {
let cli = parse(&["start", "--preset", "backend"]);
match cli.command.unwrap() {
Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_supervisor_flag() {
let cli = parse(&["start", "--supervisor"]);
match cli.command.unwrap() {
Command::Start { supervisor, .. } => assert!(supervisor),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_without_supervisor_defaults_false() {
let cli = parse(&["start", "--cli", "claude"]);
match cli.command.unwrap() {
Command::Start { supervisor, .. } => assert!(!supervisor),
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_with_supervisor_and_other_flags() {
let cli = parse(&[
"start",
"--supervisor",
"--cli",
"claude",
"--branches",
"feat/a,feat/b",
]);
match cli.command.unwrap() {
Command::Start {
supervisor,
cli,
branches,
..
} => {
assert!(supervisor);
assert_eq!(cli.as_deref(), Some("claude"));
assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn start_help_shows_supervisor_flag() {
let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(
help.contains("--supervisor"),
"start --help should contain --supervisor"
);
}
#[test]
fn start_with_all_flags() {
let cli = parse(&[
"start",
"--cli",
"gemini",
"--branches",
"a,b",
"--dry-run",
"--preset",
"dev",
]);
match cli.command.unwrap() {
Command::Start {
cli,
branches,
dry_run,
preset,
..
} => {
assert_eq!(cli.as_deref(), Some("gemini"));
assert_eq!(branches.unwrap(), vec!["a", "b"]);
assert!(dry_run);
assert_eq!(preset.as_deref(), Some("dev"));
}
other => panic!("expected Start, got {other:?}"),
}
}
#[test]
fn stop_parses() {
let cli = parse(&["stop"]);
assert!(matches!(cli.command.unwrap(), Command::Stop));
}
#[test]
fn purge_without_force() {
let cli = parse(&["purge"]);
match cli.command.unwrap() {
Command::Purge { force } => assert!(!force),
other => panic!("expected Purge, got {other:?}"),
}
}
#[test]
fn purge_with_force() {
let cli = parse(&["purge", "--force"]);
match cli.command.unwrap() {
Command::Purge { force } => assert!(force),
other => panic!("expected Purge, got {other:?}"),
}
}
#[test]
fn status_parses() {
let cli = parse(&["status"]);
assert!(matches!(cli.command.unwrap(), Command::Status));
}
#[test]
fn list_clis_parses() {
let cli = parse(&["list-clis"]);
assert!(matches!(cli.command.unwrap(), Command::ListClis));
}
#[test]
fn add_cli_with_required_args() {
let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
match cli.command.unwrap() {
Command::AddCli {
name,
command,
display_name,
} => {
assert_eq!(name, "my-agent");
assert_eq!(command, "/usr/local/bin/my-agent");
assert!(display_name.is_none());
}
other => panic!("expected AddCli, got {other:?}"),
}
}
#[test]
fn add_cli_with_display_name() {
let cli = parse(&[
"add-cli",
"my-agent",
"my-agent",
"--display-name",
"My Agent",
]);
match cli.command.unwrap() {
Command::AddCli {
name,
command,
display_name,
} => {
assert_eq!(name, "my-agent");
assert_eq!(command, "my-agent");
assert_eq!(display_name.as_deref(), Some("My Agent"));
}
other => panic!("expected AddCli, got {other:?}"),
}
}
#[test]
fn remove_cli_parses() {
let cli = parse(&["remove-cli", "my-agent"]);
match cli.command.unwrap() {
Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
other => panic!("expected RemoveCli, got {other:?}"),
}
}
#[test]
fn version_flag_is_accepted() {
let result = Cli::try_parse_from(["git-paw", "--version"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn help_flag_is_accepted() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
}
#[test]
fn init_parses() {
let cli = parse(&["init"]);
assert!(matches!(cli.command.unwrap(), Command::Init));
}
#[test]
fn init_help_text() {
let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
}
#[test]
fn unknown_subcommand_is_rejected() {
let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
assert!(result.is_err());
}
#[test]
fn add_cli_missing_required_args_is_rejected() {
let result = Cli::try_parse_from(["git-paw", "add-cli"]);
assert!(result.is_err());
}
#[test]
fn replay_with_branch() {
let cli = parse(&["replay", "feat/add-auth"]);
match cli.command.unwrap() {
Command::Replay {
branch,
list,
color,
session,
} => {
assert_eq!(branch.as_deref(), Some("feat/add-auth"));
assert!(!list);
assert!(!color);
assert!(session.is_none());
}
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_with_list() {
let cli = parse(&["replay", "--list"]);
match cli.command.unwrap() {
Command::Replay { branch, list, .. } => {
assert!(list);
assert!(branch.is_none());
}
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_with_color() {
let cli = parse(&["replay", "feat/add-auth", "--color"]);
match cli.command.unwrap() {
Command::Replay { color, .. } => assert!(color),
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_with_session() {
let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
match cli.command.unwrap() {
Command::Replay { session, .. } => {
assert_eq!(session.as_deref(), Some("paw-myproject"));
}
other => panic!("expected Replay, got {other:?}"),
}
}
#[test]
fn replay_no_args_fails() {
let result = Cli::try_parse_from(["git-paw", "replay"]);
assert!(result.is_err());
}
#[test]
fn replay_help_text() {
let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(help.contains("--list"));
assert!(help.contains("--color"));
assert!(help.contains("--session"));
}
#[test]
fn help_shows_replay_subcommand() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
let err = result.unwrap_err();
let help = err.to_string();
assert!(
help.contains("replay"),
"help should list the replay subcommand"
);
}
#[test]
fn dashboard_parses() {
let cli = parse(&["__dashboard"]);
assert!(matches!(cli.command.unwrap(), Command::Dashboard));
}
#[test]
fn dashboard_does_not_appear_in_help() {
let result = Cli::try_parse_from(["git-paw", "--help"]);
let err = result.unwrap_err();
let help = err.to_string();
assert!(
!help.contains("__dashboard"),
"hidden __dashboard subcommand should not appear in help output"
);
}
}