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, ValueEnum};
7
8/// Spec format selector for the `--specs-format` flag.
9///
10/// Three formats are supported:
11/// - `openspec` — `openspec/changes/<name>/` directory layout.
12/// - `markdown` — single-file Markdown specs with YAML frontmatter.
13/// - `speckit` — GitHub Spec Kit `.specify/specs/<feature>/` layout.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
15#[clap(rename_all = "lowercase")]
16pub enum SpecsFormat {
17    /// `OpenSpec` format (directory of `<change>/tasks.md`).
18    Openspec,
19    /// Markdown format (one `.md` file per spec with frontmatter).
20    Markdown,
21    /// Spec Kit format (`.specify/specs/<feature>/`).
22    Speckit,
23}
24
25impl SpecsFormat {
26    /// Returns the backend-dispatch string for this format.
27    #[must_use]
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Self::Openspec => "openspec",
31            Self::Markdown => "markdown",
32            Self::Speckit => "speckit",
33        }
34    }
35}
36
37/// Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions
38/// across git worktrees from a single terminal using tmux.
39#[derive(Debug, Parser)]
40#[command(
41    name = "git-paw",
42    version,
43    about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
44    long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
45                  across git worktrees from a single terminal using tmux. Each branch gets its own \
46                  worktree and AI session, running in parallel.",
47    after_help = "\x1b[1mQuick Start:\x1b[0m\n\n  \
48                  # Launch interactive session (picks CLI and branches)\n  \
49                  git paw\n\n  \
50                  # Use Claude on specific branches\n  \
51                  git paw start --cli claude --branches feat/auth,feat/api\n\n  \
52                  # Check session status\n  \
53                  git paw status\n\n  \
54                  # Pause session (detaches client, stops broker, keeps CLIs alive)\n  \
55                  git paw pause\n\n  \
56                  # Stop session (kills CLIs, preserves worktrees for later)\n  \
57                  git paw stop\n\n  \
58                  # Remove everything\n  \
59                  git paw purge"
60)]
61pub struct Cli {
62    /// Subcommand to run. Defaults to `start` if omitted.
63    #[command(subcommand)]
64    pub command: Option<Command>,
65}
66
67/// Available subcommands.
68#[derive(Debug, Subcommand)]
69pub enum Command {
70    /// Launch a new session or reattach to an existing one
71    #[command(
72        about = "Launch a new session or reattach to an existing one",
73        long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
74                      or launches a new interactive session.\n\n\
75                      By default, every existing agent branch is rebased onto the repository's \
76                      default branch (whatever `origin/HEAD` tracks — typically `main`) before \
77                      its worktree is opened, so agents always start from current main. Pass \
78                      `--no-rebase` to skip this step and reproduce the pre-v0.6 behaviour \
79                      (useful when you have local pinned SHAs or are deliberately working off a \
80                      stale baseline). If the rebase hits a conflict, the affected branch is \
81                      left at its pre-rebase HEAD and `git paw start` exits with an error \
82                      listing the conflicting files.\n\n\
83                      Examples:\n  \
84                      git paw start\n  \
85                      git paw start --cli claude\n  \
86                      git paw start --cli claude --branches feat/auth,feat/api\n  \
87                      git paw start --from-all-specs\n  \
88                      git paw start --from-all-specs --cli claude\n  \
89                      git paw start --specs add-auth,fix-session\n  \
90                      git paw start --specs   # opens spec picker (TTY required)\n  \
91                      git paw start --dry-run\n  \
92                      git paw start --preset backend\n  \
93                      git paw start --supervisor   # auto-approve safe prompts via [supervisor.auto_approve]\n  \
94                      git paw start --no-supervisor  # disable supervisor for this session (overrides config)\n  \
95                      git paw start --no-rebase   # skip rebasing agent branches onto the default branch"
96    )]
97    Start {
98        /// AI CLI to use (e.g., claude, codex, gemini). Skips CLI picker if provided.
99        #[arg(long, help = "AI CLI to use (skips CLI picker)")]
100        cli: Option<String>,
101
102        /// Comma-separated branch names. Skips branch picker if provided.
103        #[arg(
104            long,
105            value_delimiter = ',',
106            help = "Comma-separated branches (skips branch picker)"
107        )]
108        branches: Option<Vec<String>>,
109
110        /// Launch worktrees for every discovered spec across all configured formats.
111        #[arg(
112            long,
113            alias = "from-specs",
114            help = "Launch from every discovered spec across all configured formats"
115        )]
116        from_all_specs: bool,
117
118        /// Narrow the session to named specs, or open the multi-select picker
119        /// when given without values.
120        ///
121        /// `--specs add-auth,fix-session` runs only those specs. Bare `--specs`
122        /// opens a multi-select picker; an interactive terminal is required
123        /// (otherwise the command exits with an actionable error pointing at
124        /// `--specs NAME[,NAME...]` and `--from-all-specs`).
125        ///
126        /// Mutually exclusive with `--from-all-specs`.
127        #[arg(
128            long,
129            value_delimiter = ',',
130            num_args = 0..,
131            conflicts_with = "from_all_specs",
132            help = "Comma-separated spec names; bare flag opens picker (TTY required)"
133        )]
134        specs: Option<Vec<String>>,
135
136        /// Override the spec format used for `--from-all-specs` / `--specs` scanning.
137        ///
138        /// Accepted values: `openspec`, `markdown`, `speckit`. Overrides both
139        /// the `[specs] type` setting in `.git-paw/config.toml` and the
140        /// auto-detection of `.specify/` at the repo root.
141        #[arg(
142            long,
143            value_enum,
144            help = "Override spec format (openspec, markdown, speckit)"
145        )]
146        specs_format: Option<SpecsFormat>,
147
148        /// Preview the session plan without executing.
149        #[arg(long, help = "Preview the session plan without executing")]
150        dry_run: bool,
151
152        /// Use a named preset from config.
153        #[arg(long, help = "Use a named preset from config")]
154        preset: Option<String>,
155
156        /// Enable supervisor mode for this session.
157        #[arg(
158            long,
159            default_value_t = false,
160            help = "Enable supervisor mode for this session"
161        )]
162        supervisor: bool,
163
164        /// Disable supervisor mode for this session, overriding any config setting.
165        #[arg(
166            long,
167            conflicts_with = "supervisor",
168            default_value_t = false,
169            help = "Disable supervisor for this session, overriding any [supervisor] enabled = true in config"
170        )]
171        no_supervisor: bool,
172
173        /// Bypass uncommitted-spec validation warning.
174        #[arg(long, help = "Bypass uncommitted-spec validation warning")]
175        force: bool,
176
177        /// Skip rebasing existing agent branches onto the default branch
178        /// before opening their worktrees.
179        ///
180        /// By default, `git paw start` rebases every existing agent branch
181        /// onto the repository's default branch (whatever `origin/HEAD`
182        /// tracks, typically `main`) before opening or reopening its
183        /// worktree, so agents always start from current `main`. Pass
184        /// `--no-rebase` to skip the rebase step entirely and reproduce the
185        /// pre-v0.6 behaviour. Newly created branches (no prior commits) are
186        /// not rebased regardless of this flag.
187        #[arg(
188            long,
189            default_value_t = false,
190            help = "Skip rebasing existing agent branches onto the default branch before opening worktrees"
191        )]
192        no_rebase: bool,
193    },
194
195    /// Pause the session (detaches client, stops broker, leaves CLIs running)
196    #[command(
197        about = "Pause the session (detaches client, stops broker, leaves CLIs running)",
198        long_about = "Detaches the tmux client and stops the broker, but leaves all CLI \
199                      processes running in the background. This preserves agent conversation \
200                      state for instant resume via `git paw start`. RAM stays allocated \
201                      (~300 MB per Claude pane).\n\n\
202                      Use pause for short breaks (lunch, meetings, end-of-day). For longer \
203                      breaks, use `git paw stop` to kill the CLIs and release RAM (worktrees \
204                      preserved). A future `git paw hibernate` (v1.0.0) will snapshot state \
205                      to disk.\n\n\
206                      Example:\n  git paw pause"
207    )]
208    Pause,
209
210    /// Stop the session (kills tmux, keeps worktrees and state)
211    #[command(
212        about = "Stop the session (kills tmux, keeps worktrees and state)",
213        long_about = "Kills the tmux session and every CLI pane process, but preserves \
214                      worktrees and session state on disk. CLI conversation context is lost. \
215                      Run `git paw start` later to recover the session with fresh CLI \
216                      processes.\n\n\
217                      Three teardown verbs:\n  \
218                      pause — soft stop (detach + broker stop; CLIs keep running, RAM held)\n  \
219                      stop  — kills CLI processes; preserves worktrees on disk (this command)\n  \
220                      purge — full reset; removes worktrees, branches, and state\n\n\
221                      `stop` prompts for confirmation in interactive terminals. Use \
222                      `--force` to skip the prompt (scripts) or pipe stdin from \
223                      `/dev/null` for non-interactive contexts.\n\n\
224                      Examples:\n  git paw stop\n  git paw stop --force"
225    )]
226    Stop {
227        /// Skip confirmation prompt.
228        #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
229        force: bool,
230    },
231
232    /// Remove everything (tmux session, worktrees, and state)
233    #[command(
234        about = "Remove everything (tmux session, worktrees, and state)",
235        long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
236                      session state. Requires confirmation unless --force is used.\n\n\
237                      Examples:\n  git paw purge\n  git paw purge --force"
238    )]
239    Purge {
240        /// Skip confirmation prompt.
241        #[arg(long, help = "Skip confirmation prompt")]
242        force: bool,
243    },
244
245    /// Show session state for the current repo
246    #[command(
247        about = "Show session state for the current repo",
248        long_about = "Displays the current session status, branches, CLIs, and worktree paths \
249                      for the repository in the current directory.\n\n\
250                      Example:\n  git paw status"
251    )]
252    Status,
253
254    /// List detected and custom AI CLIs
255    #[command(
256        about = "List detected and custom AI CLIs",
257        long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
258                      registered in your config.\n\n\
259                      Example:\n  git paw list-clis"
260    )]
261    ListClis,
262
263    /// Register a custom AI CLI
264    #[command(
265        about = "Register a custom AI CLI",
266        long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
267                      The command can be an absolute path or a binary name on PATH.\n\n\
268                      Examples:\n  \
269                      git paw add-cli my-agent /usr/local/bin/my-agent\n  \
270                      git paw add-cli my-agent my-agent --display-name \"My Agent\""
271    )]
272    AddCli {
273        /// Name to register the CLI as.
274        #[arg(help = "Name to register the CLI as")]
275        name: String,
276
277        /// Command or path to the CLI binary.
278        #[arg(help = "Command or path to the CLI binary")]
279        command: String,
280
281        /// Optional display name for the CLI.
282        #[arg(long, help = "Display name shown in prompts")]
283        display_name: Option<String>,
284    },
285
286    /// Unregister a custom AI CLI
287    #[command(
288        about = "Unregister a custom AI CLI",
289        long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
290                      removed — auto-detected CLIs cannot.\n\n\
291                      Example:\n  git paw remove-cli my-agent"
292    )]
293    RemoveCli {
294        /// Name of the custom CLI to remove.
295        #[arg(help = "Name of the custom CLI to remove")]
296        name: String,
297    },
298
299    /// Initialize .git-paw/ directory and configuration
300    #[command(
301        about = "Initialize .git-paw/ directory and configuration",
302        long_about = "Creates the .git-paw/ directory with a default config and sets up \
303                      .gitignore for logs.\n\n\
304                      Examples:\n  git paw init"
305    )]
306    Init,
307
308    /// Internal: run the broker and dashboard in pane 0
309    #[command(
310        hide = true,
311        name = "__dashboard",
312        about = "Internal: run the broker and dashboard in pane 0",
313        long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
314                      in pane 0 of a tmux session. Not intended for direct invocation."
315    )]
316    Dashboard,
317
318    /// View captured session logs
319    #[command(
320        about = "View captured session logs",
321        long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
322                      for clean output. Use --color to view with colors via less -R.\n\n\
323                      Examples:\n  \
324                      git paw replay --list\n  \
325                      git paw replay feat/add-auth\n  \
326                      git paw replay feat/add-auth --color\n  \
327                      git paw replay feat/add-auth --session paw-myproject"
328    )]
329    Replay {
330        /// Branch name to replay (fuzzy-matched against log filenames).
331        #[arg(required_unless_present = "list", help = "Branch to replay")]
332        branch: Option<String>,
333
334        /// List available log sessions and branches.
335        #[arg(long, help = "List available log sessions and branches")]
336        list: bool,
337
338        /// Display with ANSI colors via less -R.
339        #[arg(long, help = "Display with colors via less -R")]
340        color: bool,
341
342        /// Session name to replay from (defaults to most recent).
343        #[arg(long, help = "Session to replay from (defaults to most recent)")]
344        session: Option<String>,
345    },
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use clap::Parser;
352
353    /// Helper: parse args as if running `git-paw <args>`.
354    fn parse(args: &[&str]) -> Cli {
355        let mut full = vec!["git-paw"];
356        full.extend(args);
357        Cli::try_parse_from(full).expect("failed to parse")
358    }
359
360    // -- Default subcommand --
361
362    #[test]
363    fn no_args_defaults_to_none_command() {
364        let cli = parse(&[]);
365        assert!(
366            cli.command.is_none(),
367            "no args should yield None (handled as Start in main)"
368        );
369    }
370
371    // -- Start subcommand --
372
373    #[test]
374    fn start_with_no_flags() {
375        let cli = parse(&["start"]);
376        match cli.command.unwrap() {
377            Command::Start {
378                cli,
379                branches,
380                from_all_specs,
381                specs,
382                specs_format,
383                dry_run,
384                preset,
385                supervisor,
386                no_supervisor,
387                force,
388                no_rebase,
389            } => {
390                assert!(cli.is_none());
391                assert!(branches.is_none());
392                assert!(!from_all_specs);
393                assert!(specs.is_none());
394                assert!(specs_format.is_none());
395                assert!(!dry_run);
396                assert!(preset.is_none());
397                assert!(!supervisor);
398                assert!(!no_supervisor);
399                assert!(!force);
400                assert!(!no_rebase);
401            }
402            other => panic!("expected Start, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn start_with_cli_flag() {
408        let cli = parse(&["start", "--cli", "claude"]);
409        match cli.command.unwrap() {
410            Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
411            other => panic!("expected Start, got {other:?}"),
412        }
413    }
414
415    #[test]
416    fn start_with_from_all_specs_sets_flag_and_leaves_specs_unset() {
417        let cli = parse(&["start", "--from-all-specs"]);
418        match cli.command.unwrap() {
419            Command::Start {
420                from_all_specs,
421                specs,
422                ..
423            } => {
424                assert!(from_all_specs);
425                assert!(specs.is_none());
426            }
427            other => panic!("expected Start, got {other:?}"),
428        }
429    }
430
431    #[test]
432    fn start_with_from_specs_alias_parses_identically_to_from_all_specs() {
433        let alias_args = parse(&["start", "--from-specs"]);
434        let canonical_args = parse(&["start", "--from-all-specs"]);
435        match (alias_args.command.unwrap(), canonical_args.command.unwrap()) {
436            (
437                Command::Start {
438                    from_all_specs: a_all,
439                    specs: a_specs,
440                    supervisor: a_sup,
441                    ..
442                },
443                Command::Start {
444                    from_all_specs: c_all,
445                    specs: c_specs,
446                    supervisor: c_sup,
447                    ..
448                },
449            ) => {
450                assert_eq!(a_all, c_all);
451                assert_eq!(a_specs, c_specs);
452                assert_eq!(a_sup, c_sup);
453                assert!(a_all);
454            }
455            other => panic!("expected two Start variants, got {other:?}"),
456        }
457    }
458
459    #[test]
460    fn start_with_bare_specs_yields_empty_vec_picker_mode() {
461        let cli = parse(&["start", "--specs"]);
462        match cli.command.unwrap() {
463            Command::Start {
464                from_all_specs,
465                specs,
466                ..
467            } => {
468                assert!(!from_all_specs);
469                assert_eq!(specs, Some(Vec::<String>::new()));
470            }
471            other => panic!("expected Start, got {other:?}"),
472        }
473    }
474
475    #[test]
476    fn start_with_specs_single_name() {
477        let cli = parse(&["start", "--specs", "add-auth"]);
478        match cli.command.unwrap() {
479            Command::Start { specs, .. } => {
480                assert_eq!(specs, Some(vec!["add-auth".to_string()]));
481            }
482            other => panic!("expected Start, got {other:?}"),
483        }
484    }
485
486    #[test]
487    fn start_with_specs_two_comma_separated_names() {
488        let cli = parse(&["start", "--specs", "add-auth,fix-session"]);
489        match cli.command.unwrap() {
490            Command::Start { specs, .. } => {
491                assert_eq!(
492                    specs,
493                    Some(vec!["add-auth".to_string(), "fix-session".to_string()])
494                );
495            }
496            other => panic!("expected Start, got {other:?}"),
497        }
498    }
499
500    #[test]
501    fn start_with_specs_three_comma_separated_names() {
502        let cli = parse(&["start", "--specs", "add-auth,fix-session,add-logging"]);
503        match cli.command.unwrap() {
504            Command::Start { specs, .. } => {
505                assert_eq!(
506                    specs,
507                    Some(vec![
508                        "add-auth".to_string(),
509                        "fix-session".to_string(),
510                        "add-logging".to_string(),
511                    ])
512                );
513            }
514            other => panic!("expected Start, got {other:?}"),
515        }
516    }
517
518    #[test]
519    fn start_with_from_all_specs_and_specs_is_rejected() {
520        let result = Cli::try_parse_from([
521            "git-paw",
522            "start",
523            "--from-all-specs",
524            "--specs",
525            "add-auth",
526        ]);
527        assert!(result.is_err());
528        let err = result.unwrap_err().to_string();
529        assert!(err.contains("--from-all-specs"), "got: {err}");
530        assert!(err.contains("--specs"), "got: {err}");
531    }
532
533    #[test]
534    fn start_with_from_specs_alias_and_specs_is_rejected() {
535        let result =
536            Cli::try_parse_from(["git-paw", "start", "--from-specs", "--specs", "add-auth"]);
537        assert!(result.is_err());
538    }
539
540    #[test]
541    fn start_with_from_all_specs_and_supervisor_sets_both_flags() {
542        let cli = parse(&["start", "--from-all-specs", "--supervisor"]);
543        match cli.command.unwrap() {
544            Command::Start {
545                from_all_specs,
546                specs,
547                supervisor,
548                ..
549            } => {
550                assert!(from_all_specs);
551                assert!(supervisor);
552                assert!(specs.is_none());
553            }
554            other => panic!("expected Start, got {other:?}"),
555        }
556    }
557
558    #[test]
559    fn start_with_supervisor_only_leaves_spec_mode_unset() {
560        let cli = parse(&["start", "--supervisor"]);
561        match cli.command.unwrap() {
562            Command::Start {
563                from_all_specs,
564                specs,
565                supervisor,
566                ..
567            } => {
568                assert!(!from_all_specs);
569                assert!(specs.is_none());
570                assert!(supervisor);
571            }
572            other => panic!("expected Start, got {other:?}"),
573        }
574    }
575
576    #[test]
577    fn start_help_contains_from_all_specs_and_specs_but_not_alias() {
578        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
579        let err = result.unwrap_err();
580        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
581        let help = err.to_string();
582        assert!(
583            help.contains("--from-all-specs"),
584            "start --help should contain --from-all-specs; got: {help}"
585        );
586        assert!(
587            help.contains("--specs"),
588            "start --help should contain --specs; got: {help}"
589        );
590        assert!(
591            !help.contains("--from-specs"),
592            "start --help should NOT contain hidden alias --from-specs; got: {help}"
593        );
594    }
595
596    #[test]
597    fn start_with_branches_flag_comma_separated() {
598        let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
599        match cli.command.unwrap() {
600            Command::Start { branches, .. } => {
601                let b = branches.expect("branches should be set");
602                assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
603            }
604            other => panic!("expected Start, got {other:?}"),
605        }
606    }
607
608    #[test]
609    fn start_with_dry_run() {
610        let cli = parse(&["start", "--dry-run"]);
611        match cli.command.unwrap() {
612            Command::Start { dry_run, .. } => assert!(dry_run),
613            other => panic!("expected Start, got {other:?}"),
614        }
615    }
616
617    #[test]
618    fn start_with_preset() {
619        let cli = parse(&["start", "--preset", "backend"]);
620        match cli.command.unwrap() {
621            Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
622            other => panic!("expected Start, got {other:?}"),
623        }
624    }
625
626    #[test]
627    fn start_with_supervisor_flag() {
628        let cli = parse(&["start", "--supervisor"]);
629        match cli.command.unwrap() {
630            Command::Start { supervisor, .. } => assert!(supervisor),
631            other => panic!("expected Start, got {other:?}"),
632        }
633    }
634
635    #[test]
636    fn start_without_supervisor_defaults_false() {
637        let cli = parse(&["start", "--cli", "claude"]);
638        match cli.command.unwrap() {
639            Command::Start { supervisor, .. } => assert!(!supervisor),
640            other => panic!("expected Start, got {other:?}"),
641        }
642    }
643
644    #[test]
645    fn start_with_supervisor_and_other_flags() {
646        let cli = parse(&[
647            "start",
648            "--supervisor",
649            "--cli",
650            "claude",
651            "--branches",
652            "feat/a,feat/b",
653        ]);
654        match cli.command.unwrap() {
655            Command::Start {
656                supervisor,
657                cli,
658                branches,
659                ..
660            } => {
661                assert!(supervisor);
662                assert_eq!(cli.as_deref(), Some("claude"));
663                assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
664            }
665            other => panic!("expected Start, got {other:?}"),
666        }
667    }
668
669    // -- --specs-format flag --
670
671    #[test]
672    fn start_with_specs_format_speckit() {
673        let cli = parse(&["start", "--from-specs", "--specs-format", "speckit"]);
674        match cli.command.unwrap() {
675            Command::Start { specs_format, .. } => {
676                assert_eq!(specs_format, Some(SpecsFormat::Speckit));
677            }
678            other => panic!("expected Start, got {other:?}"),
679        }
680    }
681
682    #[test]
683    fn start_with_specs_format_openspec() {
684        let cli = parse(&["start", "--from-specs", "--specs-format", "openspec"]);
685        match cli.command.unwrap() {
686            Command::Start { specs_format, .. } => {
687                assert_eq!(specs_format, Some(SpecsFormat::Openspec));
688            }
689            other => panic!("expected Start, got {other:?}"),
690        }
691    }
692
693    #[test]
694    fn start_with_specs_format_markdown() {
695        let cli = parse(&["start", "--from-specs", "--specs-format", "markdown"]);
696        match cli.command.unwrap() {
697            Command::Start { specs_format, .. } => {
698                assert_eq!(specs_format, Some(SpecsFormat::Markdown));
699            }
700            other => panic!("expected Start, got {other:?}"),
701        }
702    }
703
704    #[test]
705    fn start_rejects_unknown_specs_format() {
706        let result = Cli::try_parse_from([
707            "git-paw",
708            "start",
709            "--from-specs",
710            "--specs-format",
711            "unknown-value",
712        ]);
713        assert!(result.is_err(), "unknown value should be rejected");
714        let err = result.unwrap_err().to_string();
715        assert!(
716            err.contains("openspec") && err.contains("markdown") && err.contains("speckit"),
717            "error should list all three valid values, got: {err}"
718        );
719    }
720
721    #[test]
722    fn specs_format_as_str_matches_backend_names() {
723        assert_eq!(SpecsFormat::Openspec.as_str(), "openspec");
724        assert_eq!(SpecsFormat::Markdown.as_str(), "markdown");
725        assert_eq!(SpecsFormat::Speckit.as_str(), "speckit");
726    }
727
728    #[test]
729    fn start_help_shows_specs_format_flag() {
730        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
731        assert!(result.is_err());
732        let err = result.unwrap_err();
733        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
734        let help = err.to_string();
735        assert!(
736            help.contains("--specs-format"),
737            "start --help should contain --specs-format"
738        );
739    }
740
741    #[test]
742    fn start_help_shows_supervisor_flag() {
743        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
744        assert!(result.is_err());
745        let err = result.unwrap_err();
746        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
747        let help = err.to_string();
748        assert!(
749            help.contains("--supervisor"),
750            "start --help should contain --supervisor"
751        );
752    }
753
754    // -- --no-supervisor flag --
755
756    #[test]
757    fn start_with_no_supervisor_flag() {
758        let cli = parse(&["start", "--no-supervisor"]);
759        match cli.command.unwrap() {
760            Command::Start {
761                supervisor,
762                no_supervisor,
763                ..
764            } => {
765                assert!(no_supervisor);
766                assert!(!supervisor);
767            }
768            other => panic!("expected Start, got {other:?}"),
769        }
770    }
771
772    #[test]
773    fn start_without_flags_leaves_no_supervisor_false() {
774        let cli = parse(&["start"]);
775        match cli.command.unwrap() {
776            Command::Start {
777                supervisor,
778                no_supervisor,
779                ..
780            } => {
781                assert!(!no_supervisor);
782                assert!(!supervisor);
783            }
784            other => panic!("expected Start, got {other:?}"),
785        }
786    }
787
788    #[test]
789    fn start_with_supervisor_and_no_supervisor_is_rejected() {
790        let result = Cli::try_parse_from(["git-paw", "start", "--supervisor", "--no-supervisor"]);
791        assert!(
792            result.is_err(),
793            "--supervisor + --no-supervisor must be rejected by clap"
794        );
795        let err = result.unwrap_err();
796        let msg = err.to_string();
797        assert!(
798            msg.contains("--supervisor") && msg.contains("--no-supervisor"),
799            "error should mention both flags, got: {msg}"
800        );
801    }
802
803    #[test]
804    fn start_with_no_supervisor_and_supervisor_reversed_is_also_rejected() {
805        // clap's conflicts_with is bidirectional; order of flags shouldn't matter.
806        let result = Cli::try_parse_from(["git-paw", "start", "--no-supervisor", "--supervisor"]);
807        assert!(result.is_err());
808    }
809
810    #[test]
811    fn start_no_supervisor_combines_with_other_flags() {
812        let cli = parse(&[
813            "start",
814            "--no-supervisor",
815            "--cli",
816            "claude",
817            "--branches",
818            "feat/a,feat/b",
819        ]);
820        match cli.command.unwrap() {
821            Command::Start {
822                no_supervisor,
823                cli,
824                branches,
825                ..
826            } => {
827                assert!(no_supervisor);
828                assert_eq!(cli.as_deref(), Some("claude"));
829                assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
830            }
831            other => panic!("expected Start, got {other:?}"),
832        }
833    }
834
835    #[test]
836    fn start_help_shows_no_supervisor_flag() {
837        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
838        assert!(result.is_err());
839        let err = result.unwrap_err();
840        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
841        let help = err.to_string();
842        assert!(
843            help.contains("--no-supervisor"),
844            "start --help should contain --no-supervisor, got: {help}"
845        );
846    }
847
848    #[test]
849    fn start_with_all_flags() {
850        let cli = parse(&[
851            "start",
852            "--cli",
853            "gemini",
854            "--branches",
855            "a,b",
856            "--dry-run",
857            "--preset",
858            "dev",
859        ]);
860        match cli.command.unwrap() {
861            Command::Start {
862                cli,
863                branches,
864                dry_run,
865                preset,
866                ..
867            } => {
868                assert_eq!(cli.as_deref(), Some("gemini"));
869                assert_eq!(branches.unwrap(), vec!["a", "b"]);
870                assert!(dry_run);
871                assert_eq!(preset.as_deref(), Some("dev"));
872            }
873            other => panic!("expected Start, got {other:?}"),
874        }
875    }
876
877    // -- Pause subcommand --
878
879    #[test]
880    fn pause_parses() {
881        let cli = parse(&["pause"]);
882        assert!(matches!(cli.command.unwrap(), Command::Pause));
883    }
884
885    #[test]
886    fn pause_help_mentions_ram_tradeoff() {
887        let result = Cli::try_parse_from(["git-paw", "pause", "--help"]);
888        assert!(result.is_err());
889        let err = result.unwrap_err();
890        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
891        let help = err.to_string();
892        assert!(
893            help.to_lowercase().contains("ram"),
894            "pause --help should mention RAM tradeoff, got: {help}"
895        );
896        assert!(
897            help.contains("stop"),
898            "pause --help should cross-reference stop, got: {help}"
899        );
900    }
901
902    #[test]
903    fn pause_rejects_unknown_flags() {
904        let result = Cli::try_parse_from(["git-paw", "pause", "--anything"]);
905        assert!(result.is_err(), "pause should reject unknown flags");
906    }
907
908    #[test]
909    fn root_help_lists_pause() {
910        let result = Cli::try_parse_from(["git-paw", "--help"]);
911        assert!(result.is_err());
912        let err = result.unwrap_err();
913        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
914        let help = err.to_string();
915        assert!(
916            help.contains("pause"),
917            "root --help should list pause subcommand, got: {help}"
918        );
919        // Quick-start block should also reference pause.
920        assert!(
921            help.contains("git paw pause"),
922            "after_help quick-start should mention `git paw pause`"
923        );
924    }
925
926    // -- Stop subcommand --
927
928    #[test]
929    fn stop_parses() {
930        let cli = parse(&["stop"]);
931        assert!(matches!(
932            cli.command.unwrap(),
933            Command::Stop { force: false }
934        ));
935    }
936
937    #[test]
938    fn stop_without_force() {
939        let cli = parse(&["stop"]);
940        match cli.command.unwrap() {
941            Command::Stop { force } => assert!(!force),
942            other => panic!("expected Stop, got {other:?}"),
943        }
944    }
945
946    #[test]
947    fn stop_with_force() {
948        let cli = parse(&["stop", "--force"]);
949        match cli.command.unwrap() {
950            Command::Stop { force } => assert!(force),
951            other => panic!("expected Stop, got {other:?}"),
952        }
953    }
954
955    #[test]
956    fn stop_help_mentions_pause_and_purge() {
957        let result = Cli::try_parse_from(["git-paw", "stop", "--help"]);
958        assert!(result.is_err());
959        let err = result.unwrap_err();
960        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
961        let help = err.to_string();
962        assert!(
963            help.contains("pause"),
964            "stop --help should reference pause, got: {help}"
965        );
966        assert!(
967            help.contains("purge"),
968            "stop --help should reference purge, got: {help}"
969        );
970        assert!(
971            help.contains("--force"),
972            "stop --help should list --force, got: {help}"
973        );
974    }
975
976    // -- Purge subcommand --
977
978    #[test]
979    fn purge_without_force() {
980        let cli = parse(&["purge"]);
981        match cli.command.unwrap() {
982            Command::Purge { force } => assert!(!force),
983            other => panic!("expected Purge, got {other:?}"),
984        }
985    }
986
987    #[test]
988    fn purge_with_force() {
989        let cli = parse(&["purge", "--force"]);
990        match cli.command.unwrap() {
991            Command::Purge { force } => assert!(force),
992            other => panic!("expected Purge, got {other:?}"),
993        }
994    }
995
996    // -- Status subcommand --
997
998    #[test]
999    fn status_parses() {
1000        let cli = parse(&["status"]);
1001        assert!(matches!(cli.command.unwrap(), Command::Status));
1002    }
1003
1004    // -- List-CLIs subcommand --
1005
1006    #[test]
1007    fn list_clis_parses() {
1008        let cli = parse(&["list-clis"]);
1009        assert!(matches!(cli.command.unwrap(), Command::ListClis));
1010    }
1011
1012    // -- Add-CLI subcommand --
1013
1014    #[test]
1015    fn add_cli_with_required_args() {
1016        let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
1017        match cli.command.unwrap() {
1018            Command::AddCli {
1019                name,
1020                command,
1021                display_name,
1022            } => {
1023                assert_eq!(name, "my-agent");
1024                assert_eq!(command, "/usr/local/bin/my-agent");
1025                assert!(display_name.is_none());
1026            }
1027            other => panic!("expected AddCli, got {other:?}"),
1028        }
1029    }
1030
1031    #[test]
1032    fn add_cli_with_display_name() {
1033        let cli = parse(&[
1034            "add-cli",
1035            "my-agent",
1036            "my-agent",
1037            "--display-name",
1038            "My Agent",
1039        ]);
1040        match cli.command.unwrap() {
1041            Command::AddCli {
1042                name,
1043                command,
1044                display_name,
1045            } => {
1046                assert_eq!(name, "my-agent");
1047                assert_eq!(command, "my-agent");
1048                assert_eq!(display_name.as_deref(), Some("My Agent"));
1049            }
1050            other => panic!("expected AddCli, got {other:?}"),
1051        }
1052    }
1053
1054    // -- Remove-CLI subcommand --
1055
1056    #[test]
1057    fn remove_cli_parses() {
1058        let cli = parse(&["remove-cli", "my-agent"]);
1059        match cli.command.unwrap() {
1060            Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
1061            other => panic!("expected RemoveCli, got {other:?}"),
1062        }
1063    }
1064
1065    // -- Help text quality --
1066
1067    #[test]
1068    fn version_flag_is_accepted() {
1069        let result = Cli::try_parse_from(["git-paw", "--version"]);
1070        // clap returns Err(DisplayVersion) for --version, which is expected
1071        assert!(result.is_err());
1072        let err = result.unwrap_err();
1073        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
1074    }
1075
1076    #[test]
1077    fn help_flag_is_accepted() {
1078        let result = Cli::try_parse_from(["git-paw", "--help"]);
1079        assert!(result.is_err());
1080        let err = result.unwrap_err();
1081        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1082    }
1083
1084    // --- Gap #6: init_parses ---
1085
1086    #[test]
1087    fn init_parses() {
1088        let cli = parse(&["init"]);
1089        assert!(matches!(cli.command.unwrap(), Command::Init));
1090    }
1091
1092    // --- Gap #7: init_help_text ---
1093
1094    #[test]
1095    fn init_help_text() {
1096        let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
1097        assert!(result.is_err());
1098        let err = result.unwrap_err();
1099        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1100    }
1101
1102    #[test]
1103    fn unknown_subcommand_is_rejected() {
1104        let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
1105        assert!(result.is_err());
1106    }
1107
1108    #[test]
1109    fn add_cli_missing_required_args_is_rejected() {
1110        let result = Cli::try_parse_from(["git-paw", "add-cli"]);
1111        assert!(result.is_err());
1112    }
1113
1114    // -- Replay subcommand --
1115
1116    #[test]
1117    fn replay_with_branch() {
1118        let cli = parse(&["replay", "feat/add-auth"]);
1119        match cli.command.unwrap() {
1120            Command::Replay {
1121                branch,
1122                list,
1123                color,
1124                session,
1125            } => {
1126                assert_eq!(branch.as_deref(), Some("feat/add-auth"));
1127                assert!(!list);
1128                assert!(!color);
1129                assert!(session.is_none());
1130            }
1131            other => panic!("expected Replay, got {other:?}"),
1132        }
1133    }
1134
1135    #[test]
1136    fn replay_with_list() {
1137        let cli = parse(&["replay", "--list"]);
1138        match cli.command.unwrap() {
1139            Command::Replay { branch, list, .. } => {
1140                assert!(list);
1141                assert!(branch.is_none());
1142            }
1143            other => panic!("expected Replay, got {other:?}"),
1144        }
1145    }
1146
1147    #[test]
1148    fn replay_with_color() {
1149        let cli = parse(&["replay", "feat/add-auth", "--color"]);
1150        match cli.command.unwrap() {
1151            Command::Replay { color, .. } => assert!(color),
1152            other => panic!("expected Replay, got {other:?}"),
1153        }
1154    }
1155
1156    #[test]
1157    fn replay_with_session() {
1158        let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
1159        match cli.command.unwrap() {
1160            Command::Replay { session, .. } => {
1161                assert_eq!(session.as_deref(), Some("paw-myproject"));
1162            }
1163            other => panic!("expected Replay, got {other:?}"),
1164        }
1165    }
1166
1167    #[test]
1168    fn replay_no_args_fails() {
1169        let result = Cli::try_parse_from(["git-paw", "replay"]);
1170        assert!(result.is_err());
1171    }
1172
1173    #[test]
1174    fn replay_help_text() {
1175        let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
1176        assert!(result.is_err());
1177        let err = result.unwrap_err();
1178        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1179        let help = err.to_string();
1180        assert!(help.contains("--list"));
1181        assert!(help.contains("--color"));
1182        assert!(help.contains("--session"));
1183    }
1184
1185    #[test]
1186    fn help_shows_replay_subcommand() {
1187        let result = Cli::try_parse_from(["git-paw", "--help"]);
1188        let err = result.unwrap_err();
1189        let help = err.to_string();
1190        assert!(
1191            help.contains("replay"),
1192            "help should list the replay subcommand"
1193        );
1194    }
1195
1196    // -- __dashboard subcommand --
1197
1198    #[test]
1199    fn dashboard_parses() {
1200        let cli = parse(&["__dashboard"]);
1201        assert!(matches!(cli.command.unwrap(), Command::Dashboard));
1202    }
1203
1204    // -- --no-rebase flag --
1205
1206    #[test]
1207    fn start_with_no_rebase_flag_sets_no_rebase_true() {
1208        let cli = parse(&["start", "--no-rebase"]);
1209        match cli.command.unwrap() {
1210            Command::Start { no_rebase, .. } => assert!(no_rebase),
1211            other => panic!("expected Start, got {other:?}"),
1212        }
1213    }
1214
1215    #[test]
1216    fn start_without_no_rebase_defaults_to_false() {
1217        let cli = parse(&["start"]);
1218        match cli.command.unwrap() {
1219            Command::Start { no_rebase, .. } => assert!(!no_rebase),
1220            other => panic!("expected Start, got {other:?}"),
1221        }
1222    }
1223
1224    #[test]
1225    fn start_no_rebase_combines_with_supervisor() {
1226        let cli = parse(&["start", "--no-rebase", "--supervisor"]);
1227        match cli.command.unwrap() {
1228            Command::Start {
1229                no_rebase,
1230                supervisor,
1231                ..
1232            } => {
1233                assert!(no_rebase);
1234                assert!(supervisor);
1235            }
1236            other => panic!("expected Start, got {other:?}"),
1237        }
1238    }
1239
1240    #[test]
1241    fn start_help_shows_no_rebase_flag() {
1242        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
1243        assert!(result.is_err());
1244        let err = result.unwrap_err();
1245        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1246        let help = err.to_string();
1247        assert!(
1248            help.contains("--no-rebase"),
1249            "start --help should contain --no-rebase, got: {help}"
1250        );
1251    }
1252
1253    #[test]
1254    fn dashboard_does_not_appear_in_help() {
1255        let result = Cli::try_parse_from(["git-paw", "--help"]);
1256        let err = result.unwrap_err();
1257        let help = err.to_string();
1258        assert!(
1259            !help.contains("__dashboard"),
1260            "hidden __dashboard subcommand should not appear in help output"
1261        );
1262    }
1263}