1use clap::{Parser, Subcommand};
7
8#[derive(Debug, Parser)]
11#[command(
12 name = "git-paw",
13 version,
14 about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
15 long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
16 across git worktrees from a single terminal using tmux. Each branch gets its own \
17 worktree and AI session, running in parallel.",
18 after_help = "\x1b[1mQuick Start:\x1b[0m\n\n \
19 # Launch interactive session (picks CLI and branches)\n \
20 git paw\n\n \
21 # Use Claude on specific branches\n \
22 git paw start --cli claude --branches feat/auth,feat/api\n\n \
23 # Check session status\n \
24 git paw status\n\n \
25 # Stop session (preserves worktrees for later)\n \
26 git paw stop\n\n \
27 # Remove everything\n \
28 git paw purge"
29)]
30pub struct Cli {
31 #[command(subcommand)]
33 pub command: Option<Command>,
34}
35
36#[derive(Debug, Subcommand)]
38pub enum Command {
39 #[command(
41 about = "Launch a new session or reattach to an existing one",
42 long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
43 or launches a new interactive session.\n\n\
44 Examples:\n \
45 git paw start\n \
46 git paw start --cli claude\n \
47 git paw start --cli claude --branches feat/auth,feat/api\n \
48 git paw start --from-specs\n \
49 git paw start --from-specs --cli claude\n \
50 git paw start --dry-run\n \
51 git paw start --preset backend\n \
52 git paw start --supervisor # auto-approve safe prompts via [supervisor.auto_approve]"
53 )]
54 Start {
55 #[arg(long, help = "AI CLI to use (skips CLI picker)")]
57 cli: Option<String>,
58
59 #[arg(
61 long,
62 value_delimiter = ',',
63 help = "Comma-separated branches (skips branch picker)"
64 )]
65 branches: Option<Vec<String>>,
66
67 #[arg(
69 long,
70 help = "Launch from spec files (reads .git-paw/config.toml [specs])"
71 )]
72 from_specs: bool,
73
74 #[arg(long, help = "Preview the session plan without executing")]
76 dry_run: bool,
77
78 #[arg(long, help = "Use a named preset from config")]
80 preset: Option<String>,
81
82 #[arg(
84 long,
85 default_value_t = false,
86 help = "Enable supervisor mode for this session"
87 )]
88 supervisor: bool,
89
90 #[arg(long, help = "Bypass uncommitted-spec validation warning")]
92 force: bool,
93 },
94
95 #[command(
97 about = "Stop the session (kills tmux, keeps worktrees and state)",
98 long_about = "Kills the tmux session but preserves worktrees and session state on disk. \
99 Run `git paw start` later to recover the session.\n\n\
100 Example:\n git paw stop"
101 )]
102 Stop,
103
104 #[command(
106 about = "Remove everything (tmux session, worktrees, and state)",
107 long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
108 session state. Requires confirmation unless --force is used.\n\n\
109 Examples:\n git paw purge\n git paw purge --force"
110 )]
111 Purge {
112 #[arg(long, help = "Skip confirmation prompt")]
114 force: bool,
115 },
116
117 #[command(
119 about = "Show session state for the current repo",
120 long_about = "Displays the current session status, branches, CLIs, and worktree paths \
121 for the repository in the current directory.\n\n\
122 Example:\n git paw status"
123 )]
124 Status,
125
126 #[command(
128 about = "List detected and custom AI CLIs",
129 long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
130 registered in your config.\n\n\
131 Example:\n git paw list-clis"
132 )]
133 ListClis,
134
135 #[command(
137 about = "Register a custom AI CLI",
138 long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
139 The command can be an absolute path or a binary name on PATH.\n\n\
140 Examples:\n \
141 git paw add-cli my-agent /usr/local/bin/my-agent\n \
142 git paw add-cli my-agent my-agent --display-name \"My Agent\""
143 )]
144 AddCli {
145 #[arg(help = "Name to register the CLI as")]
147 name: String,
148
149 #[arg(help = "Command or path to the CLI binary")]
151 command: String,
152
153 #[arg(long, help = "Display name shown in prompts")]
155 display_name: Option<String>,
156 },
157
158 #[command(
160 about = "Unregister a custom AI CLI",
161 long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
162 removed — auto-detected CLIs cannot.\n\n\
163 Example:\n git paw remove-cli my-agent"
164 )]
165 RemoveCli {
166 #[arg(help = "Name of the custom CLI to remove")]
168 name: String,
169 },
170
171 #[command(
173 about = "Initialize .git-paw/ directory and configuration",
174 long_about = "Creates the .git-paw/ directory with a default config and sets up \
175 .gitignore for logs.\n\n\
176 Examples:\n git paw init"
177 )]
178 Init,
179
180 #[command(
182 hide = true,
183 name = "__dashboard",
184 about = "Internal: run the broker and dashboard in pane 0",
185 long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
186 in pane 0 of a tmux session. Not intended for direct invocation."
187 )]
188 Dashboard,
189
190 #[command(
192 about = "View captured session logs",
193 long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
194 for clean output. Use --color to view with colors via less -R.\n\n\
195 Examples:\n \
196 git paw replay --list\n \
197 git paw replay feat/add-auth\n \
198 git paw replay feat/add-auth --color\n \
199 git paw replay feat/add-auth --session paw-myproject"
200 )]
201 Replay {
202 #[arg(required_unless_present = "list", help = "Branch to replay")]
204 branch: Option<String>,
205
206 #[arg(long, help = "List available log sessions and branches")]
208 list: bool,
209
210 #[arg(long, help = "Display with colors via less -R")]
212 color: bool,
213
214 #[arg(long, help = "Session to replay from (defaults to most recent)")]
216 session: Option<String>,
217 },
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use clap::Parser;
224
225 fn parse(args: &[&str]) -> Cli {
227 let mut full = vec!["git-paw"];
228 full.extend(args);
229 Cli::try_parse_from(full).expect("failed to parse")
230 }
231
232 #[test]
235 fn no_args_defaults_to_none_command() {
236 let cli = parse(&[]);
237 assert!(
238 cli.command.is_none(),
239 "no args should yield None (handled as Start in main)"
240 );
241 }
242
243 #[test]
246 fn start_with_no_flags() {
247 let cli = parse(&["start"]);
248 match cli.command.unwrap() {
249 Command::Start {
250 cli,
251 branches,
252 from_specs,
253 dry_run,
254 preset,
255 supervisor,
256 force,
257 } => {
258 assert!(cli.is_none());
259 assert!(branches.is_none());
260 assert!(!from_specs);
261 assert!(!dry_run);
262 assert!(preset.is_none());
263 assert!(!supervisor);
264 assert!(!force);
265 }
266 other => panic!("expected Start, got {other:?}"),
267 }
268 }
269
270 #[test]
271 fn start_with_cli_flag() {
272 let cli = parse(&["start", "--cli", "claude"]);
273 match cli.command.unwrap() {
274 Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
275 other => panic!("expected Start, got {other:?}"),
276 }
277 }
278
279 #[test]
280 fn start_with_branches_flag_comma_separated() {
281 let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
282 match cli.command.unwrap() {
283 Command::Start { branches, .. } => {
284 let b = branches.expect("branches should be set");
285 assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
286 }
287 other => panic!("expected Start, got {other:?}"),
288 }
289 }
290
291 #[test]
292 fn start_with_dry_run() {
293 let cli = parse(&["start", "--dry-run"]);
294 match cli.command.unwrap() {
295 Command::Start { dry_run, .. } => assert!(dry_run),
296 other => panic!("expected Start, got {other:?}"),
297 }
298 }
299
300 #[test]
301 fn start_with_preset() {
302 let cli = parse(&["start", "--preset", "backend"]);
303 match cli.command.unwrap() {
304 Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
305 other => panic!("expected Start, got {other:?}"),
306 }
307 }
308
309 #[test]
310 fn start_with_supervisor_flag() {
311 let cli = parse(&["start", "--supervisor"]);
312 match cli.command.unwrap() {
313 Command::Start { supervisor, .. } => assert!(supervisor),
314 other => panic!("expected Start, got {other:?}"),
315 }
316 }
317
318 #[test]
319 fn start_without_supervisor_defaults_false() {
320 let cli = parse(&["start", "--cli", "claude"]);
321 match cli.command.unwrap() {
322 Command::Start { supervisor, .. } => assert!(!supervisor),
323 other => panic!("expected Start, got {other:?}"),
324 }
325 }
326
327 #[test]
328 fn start_with_supervisor_and_other_flags() {
329 let cli = parse(&[
330 "start",
331 "--supervisor",
332 "--cli",
333 "claude",
334 "--branches",
335 "feat/a,feat/b",
336 ]);
337 match cli.command.unwrap() {
338 Command::Start {
339 supervisor,
340 cli,
341 branches,
342 ..
343 } => {
344 assert!(supervisor);
345 assert_eq!(cli.as_deref(), Some("claude"));
346 assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
347 }
348 other => panic!("expected Start, got {other:?}"),
349 }
350 }
351
352 #[test]
353 fn start_help_shows_supervisor_flag() {
354 let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
355 assert!(result.is_err());
356 let err = result.unwrap_err();
357 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
358 let help = err.to_string();
359 assert!(
360 help.contains("--supervisor"),
361 "start --help should contain --supervisor"
362 );
363 }
364
365 #[test]
366 fn start_with_all_flags() {
367 let cli = parse(&[
368 "start",
369 "--cli",
370 "gemini",
371 "--branches",
372 "a,b",
373 "--dry-run",
374 "--preset",
375 "dev",
376 ]);
377 match cli.command.unwrap() {
378 Command::Start {
379 cli,
380 branches,
381 dry_run,
382 preset,
383 ..
384 } => {
385 assert_eq!(cli.as_deref(), Some("gemini"));
386 assert_eq!(branches.unwrap(), vec!["a", "b"]);
387 assert!(dry_run);
388 assert_eq!(preset.as_deref(), Some("dev"));
389 }
390 other => panic!("expected Start, got {other:?}"),
391 }
392 }
393
394 #[test]
397 fn stop_parses() {
398 let cli = parse(&["stop"]);
399 assert!(matches!(cli.command.unwrap(), Command::Stop));
400 }
401
402 #[test]
405 fn purge_without_force() {
406 let cli = parse(&["purge"]);
407 match cli.command.unwrap() {
408 Command::Purge { force } => assert!(!force),
409 other => panic!("expected Purge, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn purge_with_force() {
415 let cli = parse(&["purge", "--force"]);
416 match cli.command.unwrap() {
417 Command::Purge { force } => assert!(force),
418 other => panic!("expected Purge, got {other:?}"),
419 }
420 }
421
422 #[test]
425 fn status_parses() {
426 let cli = parse(&["status"]);
427 assert!(matches!(cli.command.unwrap(), Command::Status));
428 }
429
430 #[test]
433 fn list_clis_parses() {
434 let cli = parse(&["list-clis"]);
435 assert!(matches!(cli.command.unwrap(), Command::ListClis));
436 }
437
438 #[test]
441 fn add_cli_with_required_args() {
442 let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
443 match cli.command.unwrap() {
444 Command::AddCli {
445 name,
446 command,
447 display_name,
448 } => {
449 assert_eq!(name, "my-agent");
450 assert_eq!(command, "/usr/local/bin/my-agent");
451 assert!(display_name.is_none());
452 }
453 other => panic!("expected AddCli, got {other:?}"),
454 }
455 }
456
457 #[test]
458 fn add_cli_with_display_name() {
459 let cli = parse(&[
460 "add-cli",
461 "my-agent",
462 "my-agent",
463 "--display-name",
464 "My Agent",
465 ]);
466 match cli.command.unwrap() {
467 Command::AddCli {
468 name,
469 command,
470 display_name,
471 } => {
472 assert_eq!(name, "my-agent");
473 assert_eq!(command, "my-agent");
474 assert_eq!(display_name.as_deref(), Some("My Agent"));
475 }
476 other => panic!("expected AddCli, got {other:?}"),
477 }
478 }
479
480 #[test]
483 fn remove_cli_parses() {
484 let cli = parse(&["remove-cli", "my-agent"]);
485 match cli.command.unwrap() {
486 Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
487 other => panic!("expected RemoveCli, got {other:?}"),
488 }
489 }
490
491 #[test]
494 fn version_flag_is_accepted() {
495 let result = Cli::try_parse_from(["git-paw", "--version"]);
496 assert!(result.is_err());
498 let err = result.unwrap_err();
499 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
500 }
501
502 #[test]
503 fn help_flag_is_accepted() {
504 let result = Cli::try_parse_from(["git-paw", "--help"]);
505 assert!(result.is_err());
506 let err = result.unwrap_err();
507 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
508 }
509
510 #[test]
513 fn init_parses() {
514 let cli = parse(&["init"]);
515 assert!(matches!(cli.command.unwrap(), Command::Init));
516 }
517
518 #[test]
521 fn init_help_text() {
522 let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
523 assert!(result.is_err());
524 let err = result.unwrap_err();
525 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
526 }
527
528 #[test]
529 fn unknown_subcommand_is_rejected() {
530 let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
531 assert!(result.is_err());
532 }
533
534 #[test]
535 fn add_cli_missing_required_args_is_rejected() {
536 let result = Cli::try_parse_from(["git-paw", "add-cli"]);
537 assert!(result.is_err());
538 }
539
540 #[test]
543 fn replay_with_branch() {
544 let cli = parse(&["replay", "feat/add-auth"]);
545 match cli.command.unwrap() {
546 Command::Replay {
547 branch,
548 list,
549 color,
550 session,
551 } => {
552 assert_eq!(branch.as_deref(), Some("feat/add-auth"));
553 assert!(!list);
554 assert!(!color);
555 assert!(session.is_none());
556 }
557 other => panic!("expected Replay, got {other:?}"),
558 }
559 }
560
561 #[test]
562 fn replay_with_list() {
563 let cli = parse(&["replay", "--list"]);
564 match cli.command.unwrap() {
565 Command::Replay { branch, list, .. } => {
566 assert!(list);
567 assert!(branch.is_none());
568 }
569 other => panic!("expected Replay, got {other:?}"),
570 }
571 }
572
573 #[test]
574 fn replay_with_color() {
575 let cli = parse(&["replay", "feat/add-auth", "--color"]);
576 match cli.command.unwrap() {
577 Command::Replay { color, .. } => assert!(color),
578 other => panic!("expected Replay, got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn replay_with_session() {
584 let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
585 match cli.command.unwrap() {
586 Command::Replay { session, .. } => {
587 assert_eq!(session.as_deref(), Some("paw-myproject"));
588 }
589 other => panic!("expected Replay, got {other:?}"),
590 }
591 }
592
593 #[test]
594 fn replay_no_args_fails() {
595 let result = Cli::try_parse_from(["git-paw", "replay"]);
596 assert!(result.is_err());
597 }
598
599 #[test]
600 fn replay_help_text() {
601 let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
602 assert!(result.is_err());
603 let err = result.unwrap_err();
604 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
605 let help = err.to_string();
606 assert!(help.contains("--list"));
607 assert!(help.contains("--color"));
608 assert!(help.contains("--session"));
609 }
610
611 #[test]
612 fn help_shows_replay_subcommand() {
613 let result = Cli::try_parse_from(["git-paw", "--help"]);
614 let err = result.unwrap_err();
615 let help = err.to_string();
616 assert!(
617 help.contains("replay"),
618 "help should list the replay subcommand"
619 );
620 }
621
622 #[test]
625 fn dashboard_parses() {
626 let cli = parse(&["__dashboard"]);
627 assert!(matches!(cli.command.unwrap(), Command::Dashboard));
628 }
629
630 #[test]
631 fn dashboard_does_not_appear_in_help() {
632 let result = Cli::try_parse_from(["git-paw", "--help"]);
633 let err = result.unwrap_err();
634 let help = err.to_string();
635 assert!(
636 !help.contains("__dashboard"),
637 "hidden __dashboard subcommand should not appear in help output"
638 );
639 }
640}