Skip to main content

git_paw/
cli.rs

1//! CLI argument parsing.
2//!
3//! Defines the command-line interface using `clap` v4 with derive macros.
4//! All subcommands, flags, and options are declared here.
5
6use clap::{Parser, Subcommand};
7
8/// Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions
9/// across git worktrees from a single terminal using tmux.
10#[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    /// Subcommand to run. Defaults to `start` if omitted.
32    #[command(subcommand)]
33    pub command: Option<Command>,
34}
35
36/// Available subcommands.
37#[derive(Debug, Subcommand)]
38pub enum Command {
39    /// Launch a new session or reattach to an existing one
40    #[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        /// AI CLI to use (e.g., claude, codex, gemini). Skips CLI picker if provided.
56        #[arg(long, help = "AI CLI to use (skips CLI picker)")]
57        cli: Option<String>,
58
59        /// Comma-separated branch names. Skips branch picker if provided.
60        #[arg(
61            long,
62            value_delimiter = ',',
63            help = "Comma-separated branches (skips branch picker)"
64        )]
65        branches: Option<Vec<String>>,
66
67        /// Launch from spec files instead of interactive selection.
68        #[arg(
69            long,
70            help = "Launch from spec files (reads .git-paw/config.toml [specs])"
71        )]
72        from_specs: bool,
73
74        /// Preview the session plan without executing.
75        #[arg(long, help = "Preview the session plan without executing")]
76        dry_run: bool,
77
78        /// Use a named preset from config.
79        #[arg(long, help = "Use a named preset from config")]
80        preset: Option<String>,
81
82        /// Enable supervisor mode for this session.
83        #[arg(
84            long,
85            default_value_t = false,
86            help = "Enable supervisor mode for this session"
87        )]
88        supervisor: bool,
89
90        /// Bypass uncommitted-spec validation warning.
91        #[arg(long, help = "Bypass uncommitted-spec validation warning")]
92        force: bool,
93    },
94
95    /// Stop the session (kills tmux, keeps worktrees and state)
96    #[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    /// Remove everything (tmux session, worktrees, and state)
105    #[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        /// Skip confirmation prompt.
113        #[arg(long, help = "Skip confirmation prompt")]
114        force: bool,
115    },
116
117    /// Show session state for the current repo
118    #[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    /// List detected and custom AI CLIs
127    #[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    /// Register a custom AI CLI
136    #[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        /// Name to register the CLI as.
146        #[arg(help = "Name to register the CLI as")]
147        name: String,
148
149        /// Command or path to the CLI binary.
150        #[arg(help = "Command or path to the CLI binary")]
151        command: String,
152
153        /// Optional display name for the CLI.
154        #[arg(long, help = "Display name shown in prompts")]
155        display_name: Option<String>,
156    },
157
158    /// Unregister a custom AI CLI
159    #[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        /// Name of the custom CLI to remove.
167        #[arg(help = "Name of the custom CLI to remove")]
168        name: String,
169    },
170
171    /// Initialize .git-paw/ directory and configuration
172    #[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    /// Internal: run the broker and dashboard in pane 0
181    #[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    /// View captured session logs
191    #[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        /// Branch name to replay (fuzzy-matched against log filenames).
203        #[arg(required_unless_present = "list", help = "Branch to replay")]
204        branch: Option<String>,
205
206        /// List available log sessions and branches.
207        #[arg(long, help = "List available log sessions and branches")]
208        list: bool,
209
210        /// Display with ANSI colors via less -R.
211        #[arg(long, help = "Display with colors via less -R")]
212        color: bool,
213
214        /// Session name to replay from (defaults to most recent).
215        #[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    /// Helper: parse args as if running `git-paw <args>`.
226    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    // -- Default subcommand --
233
234    #[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    // -- Start subcommand --
244
245    #[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    // -- Stop subcommand --
395
396    #[test]
397    fn stop_parses() {
398        let cli = parse(&["stop"]);
399        assert!(matches!(cli.command.unwrap(), Command::Stop));
400    }
401
402    // -- Purge subcommand --
403
404    #[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    // -- Status subcommand --
423
424    #[test]
425    fn status_parses() {
426        let cli = parse(&["status"]);
427        assert!(matches!(cli.command.unwrap(), Command::Status));
428    }
429
430    // -- List-CLIs subcommand --
431
432    #[test]
433    fn list_clis_parses() {
434        let cli = parse(&["list-clis"]);
435        assert!(matches!(cli.command.unwrap(), Command::ListClis));
436    }
437
438    // -- Add-CLI subcommand --
439
440    #[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    // -- Remove-CLI subcommand --
481
482    #[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    // -- Help text quality --
492
493    #[test]
494    fn version_flag_is_accepted() {
495        let result = Cli::try_parse_from(["git-paw", "--version"]);
496        // clap returns Err(DisplayVersion) for --version, which is expected
497        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    // --- Gap #6: init_parses ---
511
512    #[test]
513    fn init_parses() {
514        let cli = parse(&["init"]);
515        assert!(matches!(cli.command.unwrap(), Command::Init));
516    }
517
518    // --- Gap #7: init_help_text ---
519
520    #[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    // -- Replay subcommand --
541
542    #[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    // -- __dashboard subcommand --
623
624    #[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}