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};
7use std::path::PathBuf;
8
9/// Spec format selector for the `--specs-format` flag.
10///
11/// Three formats are supported:
12/// - `openspec` — `openspec/changes/<name>/` directory layout.
13/// - `markdown` — single-file Markdown specs with YAML frontmatter.
14/// - `speckit` — GitHub Spec Kit `.specify/specs/<feature>/` layout.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
16#[clap(rename_all = "lowercase")]
17pub enum SpecsFormat {
18    /// `OpenSpec` format (directory of `<change>/tasks.md`).
19    Openspec,
20    /// Markdown format (one `.md` file per spec with frontmatter).
21    Markdown,
22    /// Spec Kit format (`.specify/specs/<feature>/`).
23    Speckit,
24}
25
26impl SpecsFormat {
27    /// Returns the backend-dispatch string for this format.
28    #[must_use]
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Openspec => "openspec",
32            Self::Markdown => "markdown",
33            Self::Speckit => "speckit",
34        }
35    }
36}
37
38/// Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions
39/// across git worktrees from a single terminal using tmux.
40#[derive(Debug, Parser)]
41#[command(
42    name = "git-paw",
43    version,
44    about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
45    long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
46                  across git worktrees from a single terminal using tmux. Each branch gets its own \
47                  worktree and AI session, running in parallel.",
48    after_help = "\x1b[1mQuick Start:\x1b[0m\n\n  \
49                  # Launch interactive session (picks CLI and branches)\n  \
50                  git paw\n\n  \
51                  # Use Claude on specific branches\n  \
52                  git paw start --cli claude --branches feat/auth,feat/api\n\n  \
53                  # Check session status\n  \
54                  git paw status\n\n  \
55                  # Pause session (detaches client, stops broker, keeps CLIs alive)\n  \
56                  git paw pause\n\n  \
57                  # Stop session (kills CLIs, preserves worktrees for later)\n  \
58                  git paw stop\n\n  \
59                  # Remove everything\n  \
60                  git paw purge"
61)]
62pub struct Cli {
63    /// Subcommand to run. Defaults to `start` if omitted.
64    #[command(subcommand)]
65    pub command: Option<Command>,
66}
67
68/// Available subcommands.
69#[derive(Debug, Subcommand)]
70pub enum Command {
71    /// Launch a new session or reattach to an existing one
72    #[command(
73        about = "Launch a new session or reattach to an existing one",
74        long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
75                      or launches a new interactive session.\n\n\
76                      By default, every existing agent branch is rebased onto the repository's \
77                      default branch (whatever `origin/HEAD` tracks — typically `main`) before \
78                      its worktree is opened, so agents always start from current main. Pass \
79                      `--no-rebase` to skip this step and reproduce the pre-v0.6 behaviour \
80                      (useful when you have local pinned SHAs or are deliberately working off a \
81                      stale baseline). If the rebase hits a conflict, the affected branch is \
82                      left at its pre-rebase HEAD and `git paw start` exits with an error \
83                      listing the conflicting files.\n\n\
84                      Examples:\n  \
85                      git paw start\n  \
86                      git paw start --cli claude\n  \
87                      git paw start --cli claude --branches feat/auth,feat/api\n  \
88                      git paw start --from-all-specs\n  \
89                      git paw start --from-all-specs --cli claude\n  \
90                      git paw start --specs add-auth,fix-session\n  \
91                      git paw start --specs   # opens spec picker (TTY required)\n  \
92                      git paw start --dry-run\n  \
93                      git paw start --preset backend\n  \
94                      git paw start --supervisor   # auto-approve safe prompts via [supervisor.auto_approve]\n  \
95                      git paw start --no-supervisor  # disable supervisor for this session (overrides config)\n  \
96                      git paw start --no-rebase   # skip rebasing agent branches onto the default branch"
97    )]
98    Start {
99        /// AI CLI to use (e.g., claude, codex, gemini). Skips CLI picker if provided.
100        #[arg(long, help = "AI CLI to use (skips CLI picker)")]
101        cli: Option<String>,
102
103        /// Comma-separated branch names. Skips branch picker if provided.
104        #[arg(
105            long,
106            value_delimiter = ',',
107            help = "Comma-separated branches (skips branch picker)"
108        )]
109        branches: Option<Vec<String>>,
110
111        /// Launch worktrees for every discovered spec across all configured formats.
112        #[arg(
113            long,
114            alias = "from-specs",
115            help = "Launch from every discovered spec across all configured formats"
116        )]
117        from_all_specs: bool,
118
119        /// Narrow the session to named specs, or open the multi-select picker
120        /// when given without values.
121        ///
122        /// `--specs add-auth,fix-session` runs only those specs. Bare `--specs`
123        /// opens a multi-select picker; an interactive terminal is required
124        /// (otherwise the command exits with an actionable error pointing at
125        /// `--specs NAME[,NAME...]` and `--from-all-specs`).
126        ///
127        /// Mutually exclusive with `--from-all-specs`.
128        #[arg(
129            long,
130            value_delimiter = ',',
131            num_args = 0..,
132            conflicts_with = "from_all_specs",
133            help = "Comma-separated spec names; bare flag opens picker (TTY required)"
134        )]
135        specs: Option<Vec<String>>,
136
137        /// Override the spec format used for `--from-all-specs` / `--specs` scanning.
138        ///
139        /// Accepted values: `openspec`, `markdown`, `speckit`. Overrides both
140        /// the `[specs] type` setting in `.git-paw/config.toml` and the
141        /// auto-detection of `.specify/` at the repo root.
142        #[arg(
143            long,
144            value_enum,
145            help = "Override spec format (openspec, markdown, speckit)"
146        )]
147        specs_format: Option<SpecsFormat>,
148
149        /// Preview the session plan without executing.
150        #[arg(long, help = "Preview the session plan without executing")]
151        dry_run: bool,
152
153        /// Use a named preset from config.
154        #[arg(long, help = "Use a named preset from config")]
155        preset: Option<String>,
156
157        /// Enable supervisor mode for this session.
158        #[arg(
159            long,
160            default_value_t = false,
161            help = "Enable supervisor mode for this session"
162        )]
163        supervisor: bool,
164
165        /// Disable supervisor mode for this session, overriding any config setting.
166        #[arg(
167            long,
168            conflicts_with = "supervisor",
169            default_value_t = false,
170            help = "Disable supervisor for this session, overriding any [supervisor] enabled = true in config"
171        )]
172        no_supervisor: bool,
173
174        /// Bypass uncommitted-spec validation warning.
175        #[arg(long, help = "Bypass uncommitted-spec validation warning")]
176        force: bool,
177
178        /// Skip rebasing existing agent branches onto the default branch
179        /// before opening their worktrees.
180        ///
181        /// By default, `git paw start` rebases every existing agent branch
182        /// onto the repository's default branch (whatever `origin/HEAD`
183        /// tracks, typically `main`) before opening or reopening its
184        /// worktree, so agents always start from current `main`. Pass
185        /// `--no-rebase` to skip the rebase step entirely and reproduce the
186        /// pre-v0.6 behaviour. Newly created branches (no prior commits) are
187        /// not rebased regardless of this flag.
188        #[arg(
189            long,
190            default_value_t = false,
191            help = "Skip rebasing existing agent branches onto the default branch before opening worktrees"
192        )]
193        no_rebase: bool,
194    },
195
196    /// Attach a new worktree + agent pane to a running session
197    #[command(
198        about = "Attach a new worktree + agent pane to a running session",
199        long_about = "Hot-attaches a worktree and agent pane to an already-running session — \
200                      no stop/purge/restart, the other agents keep working undisturbed. The \
201                      agent grid re-tiles to the layout a start of that many agents would \
202                      produce, the new branch is registered in the session, and the agent boots \
203                      with the same broker boot block + initial prompt a start-time agent gets.\n\n\
204                      Provide a branch name, or use --from-spec to derive the branch (and CLI) \
205                      from a discovered spec. Adding past the 25-agent cap is rejected. When the \
206                      session is paused, the new pane starts paused too and begins on the next \
207                      `git paw resume`. The supervisor (if any) discovers the new agent on its \
208                      next broker poll — no restart.\n\n\
209                      Examples:\n  \
210                      git paw add feat/new-thing\n  \
211                      git paw add feat/api --cli codex\n  \
212                      git paw add --from-spec add-export"
213    )]
214    Add {
215        /// Branch to attach. Omit when using --from-spec (the branch is
216        /// derived from the spec).
217        #[arg(
218            required_unless_present = "from_spec",
219            help = "Branch to attach (omit when using --from-spec)"
220        )]
221        branch: Option<String>,
222
223        /// AI CLI to launch in the new pane (defaults to the session's CLI).
224        #[arg(
225            long,
226            help = "AI CLI for the new pane (defaults to the session's default CLI)"
227        )]
228        cli: Option<String>,
229
230        /// Resolve the branch name and CLI from a discovered spec instead of a
231        /// positional branch argument.
232        #[arg(
233            long,
234            conflicts_with = "branch",
235            help = "Derive branch + CLI from a spec (OpenSpec change, Markdown spec, or Spec Kit feature)"
236        )]
237        from_spec: Option<String>,
238    },
239
240    /// Detach a single agent from a running session
241    #[command(
242        about = "Detach a single agent from a running session",
243        long_about = "Removes one agent from an active session: closes its tmux pane, re-tiles \
244                      the grid for the smaller agent count, removes its worktree (reusing \
245                      `git paw purge`'s per-worktree teardown), and drops it from the session. \
246                      The other agents are left untouched.\n\n\
247                      Safe by default: `remove` refuses to delete a worktree with uncommitted \
248                      changes (it lists what would be lost) unless you pass --force. Pass \
249                      --keep-worktree to detach the pane + session entry but leave the worktree \
250                      and branch on disk (this skips the uncommitted-work check, since nothing \
251                      is deleted). `remove supervisor` is refused — use `git paw stop` to end \
252                      the whole session. The supervisor notices the departure on its next broker \
253                      poll (the agent stops heartbeating) — no restart.\n\n\
254                      Examples:\n  \
255                      git paw remove feat/done-thing\n  \
256                      git paw remove feat/wip --force\n  \
257                      git paw remove feat/keep --keep-worktree"
258    )]
259    Remove {
260        /// Branch of the agent to remove.
261        #[arg(help = "Branch of the agent to remove")]
262        branch: String,
263
264        /// Detach the pane + session entry but leave the worktree and branch
265        /// on disk (skips the uncommitted-work safety check).
266        #[arg(
267            long,
268            help = "Leave the worktree + branch on disk; only detach the pane and session entry"
269        )]
270        keep_worktree: bool,
271
272        /// Remove the worktree even when it has uncommitted changes.
273        #[arg(
274            long,
275            help = "Remove even with uncommitted changes (bypass the safety check)"
276        )]
277        force: bool,
278    },
279
280    /// Pause the session (detaches client, stops broker, leaves CLIs running)
281    #[command(
282        about = "Pause the session (detaches client, stops broker, leaves CLIs running)",
283        long_about = "Detaches the tmux client and stops the broker, but leaves all CLI \
284                      processes running in the background. This preserves agent conversation \
285                      state for instant resume via `git paw start`. RAM stays allocated \
286                      (~300 MB per Claude pane).\n\n\
287                      Use pause for short breaks (lunch, meetings, end-of-day). For longer \
288                      breaks, use `git paw stop` to kill the CLIs and release RAM (worktrees \
289                      preserved). A future `git paw hibernate` (v1.0.0) will snapshot state \
290                      to disk.\n\n\
291                      Example:\n  git paw pause"
292    )]
293    Pause,
294
295    /// Stop the session (kills tmux, keeps worktrees and state)
296    #[command(
297        about = "Stop the session (kills tmux, keeps worktrees and state)",
298        long_about = "Kills the tmux session and every CLI pane process, but preserves \
299                      worktrees and session state on disk. CLI conversation context is lost. \
300                      Run `git paw start` later to recover the session with fresh CLI \
301                      processes.\n\n\
302                      Three teardown verbs:\n  \
303                      pause — soft stop (detach + broker stop; CLIs keep running, RAM held)\n  \
304                      stop  — kills CLI processes; preserves worktrees on disk (this command)\n  \
305                      purge — full reset; removes worktrees, branches, and state\n\n\
306                      `stop` prompts for confirmation in interactive terminals. Use \
307                      `--force` to skip the prompt (scripts) or pipe stdin from \
308                      `/dev/null` for non-interactive contexts.\n\n\
309                      Examples:\n  git paw stop\n  git paw stop --force"
310    )]
311    Stop {
312        /// Skip confirmation prompt.
313        #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
314        force: bool,
315    },
316
317    /// Remove everything (tmux session, worktrees, and state)
318    #[command(
319        about = "Remove everything (tmux session, worktrees, and state)",
320        long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
321                      session state. Requires confirmation unless --force is used.\n\n\
322                      Use --stale to purge only sessions whose tmux session is gone (a stale \
323                      receipt). Live sessions are left untouched, so --stale is safe in cleanup \
324                      scripts. Pairing --stale with --force is a no-op (--force is redundant on \
325                      a stale entry).\n\n\
326                      Examples:\n  git paw purge\n  git paw purge --force\n  git paw purge --stale"
327    )]
328    Purge {
329        /// Skip confirmation prompt.
330        #[arg(long, help = "Skip confirmation prompt")]
331        force: bool,
332        /// Purge only stale sessions (receipt claims active but tmux is gone).
333        #[arg(
334            long,
335            help = "Purge only stale sessions (receipt claims active but tmux is gone); \
336                    live sessions untouched"
337        )]
338        stale: bool,
339    },
340
341    /// Show session state for the current repo
342    #[command(
343        about = "Show session state for the current repo",
344        long_about = "Displays the current session status, branches, CLIs, and worktree paths \
345                      for the repository in the current directory.\n\n\
346                      Status is one of 🟢 active (tmux running), 🔵 paused, 🟡 stopped, or \
347                      🔴 stale (the receipt claims active but the tmux session no longer \
348                      exists — a crash or release-boundary carry-over). Run `git paw start` to \
349                      self-heal a stale receipt, or `git paw purge --stale` to clear it.\n\n\
350                      Pass --json for machine-readable output (the `status` field is one of \
351                      active/paused/stopped/stale).\n\n\
352                      Examples:\n  git paw status\n  git paw status --json"
353    )]
354    Status {
355        /// Emit machine-readable JSON instead of the human-readable display.
356        #[arg(long, help = "Emit machine-readable JSON")]
357        json: bool,
358    },
359
360    /// List detected and custom AI CLIs
361    #[command(
362        about = "List detected and custom AI CLIs",
363        long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
364                      registered in your config.\n\n\
365                      Example:\n  git paw list-clis"
366    )]
367    ListClis,
368
369    /// Register a custom AI CLI
370    #[command(
371        about = "Register a custom AI CLI",
372        long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
373                      The command can be an absolute path or a binary name on PATH.\n\n\
374                      Examples:\n  \
375                      git paw add-cli my-agent /usr/local/bin/my-agent\n  \
376                      git paw add-cli my-agent my-agent --display-name \"My Agent\""
377    )]
378    AddCli {
379        /// Name to register the CLI as.
380        #[arg(help = "Name to register the CLI as")]
381        name: String,
382
383        /// Command or path to the CLI binary.
384        #[arg(help = "Command or path to the CLI binary")]
385        command: String,
386
387        /// Optional display name for the CLI.
388        #[arg(long, help = "Display name shown in prompts")]
389        display_name: Option<String>,
390    },
391
392    /// Unregister a custom AI CLI
393    #[command(
394        about = "Unregister a custom AI CLI",
395        long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
396                      removed — auto-detected CLIs cannot.\n\n\
397                      Example:\n  git paw remove-cli my-agent"
398    )]
399    RemoveCli {
400        /// Name of the custom CLI to remove.
401        #[arg(help = "Name of the custom CLI to remove")]
402        name: String,
403    },
404
405    /// Initialize .git-paw/ directory and configuration
406    #[command(
407        about = "Initialize .git-paw/ directory and configuration",
408        long_about = "Creates the .git-paw/ directory with a default config and sets up \
409                      .gitignore for logs.\n\n\
410                      Examples:\n  git paw init"
411    )]
412    Init,
413
414    /// Internal: run the broker and dashboard in pane 0
415    #[command(
416        hide = true,
417        name = "__dashboard",
418        about = "Internal: run the broker and dashboard in pane 0",
419        long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
420                      in pane 0 of a tmux session. Not intended for direct invocation."
421    )]
422    Dashboard,
423
424    /// View captured session logs
425    #[command(
426        about = "View captured session logs",
427        long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
428                      for clean output. Use --color to view with colors via less -R.\n\n\
429                      Examples:\n  \
430                      git paw replay --list\n  \
431                      git paw replay feat/add-auth\n  \
432                      git paw replay feat/add-auth --color\n  \
433                      git paw replay feat/add-auth --session paw-myproject"
434    )]
435    Replay {
436        /// Branch name to replay (fuzzy-matched against log filenames).
437        #[arg(required_unless_present = "list", help = "Branch to replay")]
438        branch: Option<String>,
439
440        /// List available log sessions and branches.
441        #[arg(long, help = "List available log sessions and branches")]
442        list: bool,
443
444        /// Display with ANSI colors via less -R.
445        #[arg(long, help = "Display with colors via less -R")]
446        color: bool,
447
448        /// Session name to replay from (defaults to most recent).
449        #[arg(long, help = "Session to replay from (defaults to most recent)")]
450        session: Option<String>,
451    },
452
453    /// Report manually-approved command patterns for a session
454    #[command(
455        about = "Report manually-approved command patterns for a session",
456        long_about = "Lists the command patterns you manually approved during a session — the \
457                      prompts the auto-approve preset did NOT match — sorted by how often each \
458                      was approved. Each row carries a SUGGEST hint for where the pattern might \
459                      be promoted: the project-local allowlist (project-specific scripts/paths) \
460                      or the bundled dev-allowlist preset (general commands like `make <target>`). \
461                      The suggestion is a hint, not a rule.\n\n\
462                      Reads `.git-paw/sessions/<session>.manual-approvals.jsonl`. Defaults to the \
463                      active session; pass --session to target another. Recording is controlled by \
464                      `[supervisor] manual_approvals_log` (default on).\n\n\
465                      Examples:\n  \
466                      git paw approvals\n  \
467                      git paw approvals --json\n  \
468                      git paw approvals --session paw-myproject\n  \
469                      git paw approvals --limit 5"
470    )]
471    Approvals {
472        /// Session to read approvals from (defaults to the active session).
473        #[arg(long, help = "Session to read from (defaults to the active session)")]
474        session: Option<String>,
475
476        /// Cap the output to the top N patterns by count.
477        #[arg(long, help = "Show at most N patterns (top N by count)")]
478        limit: Option<usize>,
479
480        /// Emit machine-readable JSON instead of the text table.
481        #[arg(long, help = "Emit machine-readable JSON")]
482        json: bool,
483    },
484
485    /// Run a read-only Model Context Protocol (MCP) server over stdio
486    #[command(
487        about = "Run a read-only Model Context Protocol (MCP) server over stdio",
488        long_about = "Starts a Model Context Protocol (MCP) server on stdin/stdout so any \
489                      MCP-aware client (Claude Desktop, Cursor, ChatGPT Desktop, Windsurf, \
490                      VS Code MCP) can query this repository's read-only state: agent \
491                      coordination intents/conflicts, governance docs, specs and tasks, \
492                      session status and learnings, agent skills, git context, source \
493                      browsing (list_files, read_file, search_code over the local working \
494                      tree), and the repository's own README and documentation.\n\n\
495                      The server is client-spawned and one-shot: the MCP client owns the \
496                      process lifecycle and the server exits when stdin is closed. It runs \
497                      standalone — no tmux session, broker, or supervisor is required. When a \
498                      data source is unavailable (no broker, no session, no governance config) \
499                      tools return well-formed empty/null results rather than errors.\n\n\
500                      Repository resolution: --repo wins; otherwise the nearest ancestor of the \
501                      current directory containing .git is used (worktrees resolve to their own \
502                      root). Claude Desktop spawns servers from its app-support directory, so it \
503                      MUST pass --repo with an absolute path.\n\n\
504                      Claude Desktop config (claude_desktop_config.json):\n\n  \
505                      {\n    \
506                        \"mcpServers\": {\n      \
507                          \"git-paw\": {\n        \
508                            \"command\": \"git\",\n        \
509                            \"args\": [\"paw\", \"mcp\", \"--repo\", \"/absolute/path/to/your/repo\"]\n      \
510                          }\n    \
511                        }\n  \
512                      }\n\n\
513                      Examples:\n  \
514                      git paw mcp\n  \
515                      git paw mcp --repo /path/to/repo\n  \
516                      git paw mcp --repo /path/to/repo --log-file /tmp/git-paw-mcp.log"
517    )]
518    Mcp {
519        /// Repository to operate against, overriding current-directory
520        /// discovery. Required for clients that spawn from a fixed directory
521        /// (notably Claude Desktop).
522        #[arg(
523            long,
524            value_name = "PATH",
525            help = "Repository to serve (overrides current-directory discovery; required for Claude Desktop)"
526        )]
527        repo: Option<PathBuf>,
528
529        /// Write tracing output to this file in addition to stderr (off by
530        /// default). Stdout always stays reserved for the JSON-RPC stream.
531        #[arg(
532            long,
533            value_name = "PATH",
534            help = "Also write tracing output to this file (stderr is always used)"
535        )]
536        log_file: Option<PathBuf>,
537    },
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use clap::Parser;
544
545    /// Helper: parse args as if running `git-paw <args>`.
546    fn parse(args: &[&str]) -> Cli {
547        let mut full = vec!["git-paw"];
548        full.extend(args);
549        Cli::try_parse_from(full).expect("failed to parse")
550    }
551
552    // -- Default subcommand --
553
554    #[test]
555    fn no_args_defaults_to_none_command() {
556        let cli = parse(&[]);
557        assert!(
558            cli.command.is_none(),
559            "no args should yield None (handled as Start in main)"
560        );
561    }
562
563    // -- Start subcommand --
564
565    #[test]
566    fn start_with_no_flags() {
567        let cli = parse(&["start"]);
568        match cli.command.unwrap() {
569            Command::Start {
570                cli,
571                branches,
572                from_all_specs,
573                specs,
574                specs_format,
575                dry_run,
576                preset,
577                supervisor,
578                no_supervisor,
579                force,
580                no_rebase,
581            } => {
582                assert!(cli.is_none());
583                assert!(branches.is_none());
584                assert!(!from_all_specs);
585                assert!(specs.is_none());
586                assert!(specs_format.is_none());
587                assert!(!dry_run);
588                assert!(preset.is_none());
589                assert!(!supervisor);
590                assert!(!no_supervisor);
591                assert!(!force);
592                assert!(!no_rebase);
593            }
594            other => panic!("expected Start, got {other:?}"),
595        }
596    }
597
598    #[test]
599    fn start_with_cli_flag() {
600        let cli = parse(&["start", "--cli", "claude"]);
601        match cli.command.unwrap() {
602            Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
603            other => panic!("expected Start, got {other:?}"),
604        }
605    }
606
607    #[test]
608    fn start_with_from_all_specs_sets_flag_and_leaves_specs_unset() {
609        let cli = parse(&["start", "--from-all-specs"]);
610        match cli.command.unwrap() {
611            Command::Start {
612                from_all_specs,
613                specs,
614                ..
615            } => {
616                assert!(from_all_specs);
617                assert!(specs.is_none());
618            }
619            other => panic!("expected Start, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn start_with_from_specs_alias_parses_identically_to_from_all_specs() {
625        let alias_args = parse(&["start", "--from-specs"]);
626        let canonical_args = parse(&["start", "--from-all-specs"]);
627        match (alias_args.command.unwrap(), canonical_args.command.unwrap()) {
628            (
629                Command::Start {
630                    from_all_specs: a_all,
631                    specs: a_specs,
632                    supervisor: a_sup,
633                    ..
634                },
635                Command::Start {
636                    from_all_specs: c_all,
637                    specs: c_specs,
638                    supervisor: c_sup,
639                    ..
640                },
641            ) => {
642                assert_eq!(a_all, c_all);
643                assert_eq!(a_specs, c_specs);
644                assert_eq!(a_sup, c_sup);
645                assert!(a_all);
646            }
647            other => panic!("expected two Start variants, got {other:?}"),
648        }
649    }
650
651    #[test]
652    fn start_with_bare_specs_yields_empty_vec_picker_mode() {
653        let cli = parse(&["start", "--specs"]);
654        match cli.command.unwrap() {
655            Command::Start {
656                from_all_specs,
657                specs,
658                ..
659            } => {
660                assert!(!from_all_specs);
661                assert_eq!(specs, Some(Vec::<String>::new()));
662            }
663            other => panic!("expected Start, got {other:?}"),
664        }
665    }
666
667    #[test]
668    fn start_with_specs_single_name() {
669        let cli = parse(&["start", "--specs", "add-auth"]);
670        match cli.command.unwrap() {
671            Command::Start { specs, .. } => {
672                assert_eq!(specs, Some(vec!["add-auth".to_string()]));
673            }
674            other => panic!("expected Start, got {other:?}"),
675        }
676    }
677
678    #[test]
679    fn start_with_specs_two_comma_separated_names() {
680        let cli = parse(&["start", "--specs", "add-auth,fix-session"]);
681        match cli.command.unwrap() {
682            Command::Start { specs, .. } => {
683                assert_eq!(
684                    specs,
685                    Some(vec!["add-auth".to_string(), "fix-session".to_string()])
686                );
687            }
688            other => panic!("expected Start, got {other:?}"),
689        }
690    }
691
692    #[test]
693    fn start_with_specs_three_comma_separated_names() {
694        let cli = parse(&["start", "--specs", "add-auth,fix-session,add-logging"]);
695        match cli.command.unwrap() {
696            Command::Start { specs, .. } => {
697                assert_eq!(
698                    specs,
699                    Some(vec![
700                        "add-auth".to_string(),
701                        "fix-session".to_string(),
702                        "add-logging".to_string(),
703                    ])
704                );
705            }
706            other => panic!("expected Start, got {other:?}"),
707        }
708    }
709
710    #[test]
711    fn start_with_from_all_specs_and_specs_is_rejected() {
712        let result = Cli::try_parse_from([
713            "git-paw",
714            "start",
715            "--from-all-specs",
716            "--specs",
717            "add-auth",
718        ]);
719        assert!(result.is_err());
720        let err = result.unwrap_err().to_string();
721        assert!(err.contains("--from-all-specs"), "got: {err}");
722        assert!(err.contains("--specs"), "got: {err}");
723    }
724
725    #[test]
726    fn start_with_from_specs_alias_and_specs_is_rejected() {
727        let result =
728            Cli::try_parse_from(["git-paw", "start", "--from-specs", "--specs", "add-auth"]);
729        assert!(result.is_err());
730    }
731
732    #[test]
733    fn start_with_from_all_specs_and_supervisor_sets_both_flags() {
734        let cli = parse(&["start", "--from-all-specs", "--supervisor"]);
735        match cli.command.unwrap() {
736            Command::Start {
737                from_all_specs,
738                specs,
739                supervisor,
740                ..
741            } => {
742                assert!(from_all_specs);
743                assert!(supervisor);
744                assert!(specs.is_none());
745            }
746            other => panic!("expected Start, got {other:?}"),
747        }
748    }
749
750    #[test]
751    fn start_with_supervisor_only_leaves_spec_mode_unset() {
752        let cli = parse(&["start", "--supervisor"]);
753        match cli.command.unwrap() {
754            Command::Start {
755                from_all_specs,
756                specs,
757                supervisor,
758                ..
759            } => {
760                assert!(!from_all_specs);
761                assert!(specs.is_none());
762                assert!(supervisor);
763            }
764            other => panic!("expected Start, got {other:?}"),
765        }
766    }
767
768    #[test]
769    fn start_help_contains_from_all_specs_and_specs_but_not_alias() {
770        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
771        let err = result.unwrap_err();
772        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
773        let help = err.to_string();
774        assert!(
775            help.contains("--from-all-specs"),
776            "start --help should contain --from-all-specs; got: {help}"
777        );
778        assert!(
779            help.contains("--specs"),
780            "start --help should contain --specs; got: {help}"
781        );
782        assert!(
783            !help.contains("--from-specs"),
784            "start --help should NOT contain hidden alias --from-specs; got: {help}"
785        );
786    }
787
788    #[test]
789    fn start_with_branches_flag_comma_separated() {
790        let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
791        match cli.command.unwrap() {
792            Command::Start { branches, .. } => {
793                let b = branches.expect("branches should be set");
794                assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
795            }
796            other => panic!("expected Start, got {other:?}"),
797        }
798    }
799
800    #[test]
801    fn start_with_dry_run() {
802        let cli = parse(&["start", "--dry-run"]);
803        match cli.command.unwrap() {
804            Command::Start { dry_run, .. } => assert!(dry_run),
805            other => panic!("expected Start, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn start_with_preset() {
811        let cli = parse(&["start", "--preset", "backend"]);
812        match cli.command.unwrap() {
813            Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
814            other => panic!("expected Start, got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn start_with_supervisor_flag() {
820        let cli = parse(&["start", "--supervisor"]);
821        match cli.command.unwrap() {
822            Command::Start { supervisor, .. } => assert!(supervisor),
823            other => panic!("expected Start, got {other:?}"),
824        }
825    }
826
827    #[test]
828    fn start_without_supervisor_defaults_false() {
829        let cli = parse(&["start", "--cli", "claude"]);
830        match cli.command.unwrap() {
831            Command::Start { supervisor, .. } => assert!(!supervisor),
832            other => panic!("expected Start, got {other:?}"),
833        }
834    }
835
836    #[test]
837    fn start_with_supervisor_and_other_flags() {
838        let cli = parse(&[
839            "start",
840            "--supervisor",
841            "--cli",
842            "claude",
843            "--branches",
844            "feat/a,feat/b",
845        ]);
846        match cli.command.unwrap() {
847            Command::Start {
848                supervisor,
849                cli,
850                branches,
851                ..
852            } => {
853                assert!(supervisor);
854                assert_eq!(cli.as_deref(), Some("claude"));
855                assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
856            }
857            other => panic!("expected Start, got {other:?}"),
858        }
859    }
860
861    // -- --specs-format flag --
862
863    #[test]
864    fn start_with_specs_format_speckit() {
865        let cli = parse(&["start", "--from-specs", "--specs-format", "speckit"]);
866        match cli.command.unwrap() {
867            Command::Start { specs_format, .. } => {
868                assert_eq!(specs_format, Some(SpecsFormat::Speckit));
869            }
870            other => panic!("expected Start, got {other:?}"),
871        }
872    }
873
874    #[test]
875    fn start_with_specs_format_openspec() {
876        let cli = parse(&["start", "--from-specs", "--specs-format", "openspec"]);
877        match cli.command.unwrap() {
878            Command::Start { specs_format, .. } => {
879                assert_eq!(specs_format, Some(SpecsFormat::Openspec));
880            }
881            other => panic!("expected Start, got {other:?}"),
882        }
883    }
884
885    #[test]
886    fn start_with_specs_format_markdown() {
887        let cli = parse(&["start", "--from-specs", "--specs-format", "markdown"]);
888        match cli.command.unwrap() {
889            Command::Start { specs_format, .. } => {
890                assert_eq!(specs_format, Some(SpecsFormat::Markdown));
891            }
892            other => panic!("expected Start, got {other:?}"),
893        }
894    }
895
896    #[test]
897    fn start_rejects_unknown_specs_format() {
898        let result = Cli::try_parse_from([
899            "git-paw",
900            "start",
901            "--from-specs",
902            "--specs-format",
903            "unknown-value",
904        ]);
905        assert!(result.is_err(), "unknown value should be rejected");
906        let err = result.unwrap_err().to_string();
907        assert!(
908            err.contains("openspec") && err.contains("markdown") && err.contains("speckit"),
909            "error should list all three valid values, got: {err}"
910        );
911    }
912
913    #[test]
914    fn specs_format_as_str_matches_backend_names() {
915        assert_eq!(SpecsFormat::Openspec.as_str(), "openspec");
916        assert_eq!(SpecsFormat::Markdown.as_str(), "markdown");
917        assert_eq!(SpecsFormat::Speckit.as_str(), "speckit");
918    }
919
920    #[test]
921    fn start_help_shows_specs_format_flag() {
922        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
923        assert!(result.is_err());
924        let err = result.unwrap_err();
925        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
926        let help = err.to_string();
927        assert!(
928            help.contains("--specs-format"),
929            "start --help should contain --specs-format"
930        );
931    }
932
933    #[test]
934    fn start_help_shows_supervisor_flag() {
935        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
936        assert!(result.is_err());
937        let err = result.unwrap_err();
938        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
939        let help = err.to_string();
940        assert!(
941            help.contains("--supervisor"),
942            "start --help should contain --supervisor"
943        );
944    }
945
946    // -- --no-supervisor flag --
947
948    #[test]
949    fn start_with_no_supervisor_flag() {
950        let cli = parse(&["start", "--no-supervisor"]);
951        match cli.command.unwrap() {
952            Command::Start {
953                supervisor,
954                no_supervisor,
955                ..
956            } => {
957                assert!(no_supervisor);
958                assert!(!supervisor);
959            }
960            other => panic!("expected Start, got {other:?}"),
961        }
962    }
963
964    #[test]
965    fn start_without_flags_leaves_no_supervisor_false() {
966        let cli = parse(&["start"]);
967        match cli.command.unwrap() {
968            Command::Start {
969                supervisor,
970                no_supervisor,
971                ..
972            } => {
973                assert!(!no_supervisor);
974                assert!(!supervisor);
975            }
976            other => panic!("expected Start, got {other:?}"),
977        }
978    }
979
980    #[test]
981    fn start_with_supervisor_and_no_supervisor_is_rejected() {
982        let result = Cli::try_parse_from(["git-paw", "start", "--supervisor", "--no-supervisor"]);
983        assert!(
984            result.is_err(),
985            "--supervisor + --no-supervisor must be rejected by clap"
986        );
987        let err = result.unwrap_err();
988        let msg = err.to_string();
989        assert!(
990            msg.contains("--supervisor") && msg.contains("--no-supervisor"),
991            "error should mention both flags, got: {msg}"
992        );
993    }
994
995    #[test]
996    fn start_with_no_supervisor_and_supervisor_reversed_is_also_rejected() {
997        // clap's conflicts_with is bidirectional; order of flags shouldn't matter.
998        let result = Cli::try_parse_from(["git-paw", "start", "--no-supervisor", "--supervisor"]);
999        assert!(result.is_err());
1000    }
1001
1002    #[test]
1003    fn start_no_supervisor_combines_with_other_flags() {
1004        let cli = parse(&[
1005            "start",
1006            "--no-supervisor",
1007            "--cli",
1008            "claude",
1009            "--branches",
1010            "feat/a,feat/b",
1011        ]);
1012        match cli.command.unwrap() {
1013            Command::Start {
1014                no_supervisor,
1015                cli,
1016                branches,
1017                ..
1018            } => {
1019                assert!(no_supervisor);
1020                assert_eq!(cli.as_deref(), Some("claude"));
1021                assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
1022            }
1023            other => panic!("expected Start, got {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn start_help_shows_no_supervisor_flag() {
1029        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
1030        assert!(result.is_err());
1031        let err = result.unwrap_err();
1032        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1033        let help = err.to_string();
1034        assert!(
1035            help.contains("--no-supervisor"),
1036            "start --help should contain --no-supervisor, got: {help}"
1037        );
1038    }
1039
1040    #[test]
1041    fn start_with_all_flags() {
1042        let cli = parse(&[
1043            "start",
1044            "--cli",
1045            "gemini",
1046            "--branches",
1047            "a,b",
1048            "--dry-run",
1049            "--preset",
1050            "dev",
1051        ]);
1052        match cli.command.unwrap() {
1053            Command::Start {
1054                cli,
1055                branches,
1056                dry_run,
1057                preset,
1058                ..
1059            } => {
1060                assert_eq!(cli.as_deref(), Some("gemini"));
1061                assert_eq!(branches.unwrap(), vec!["a", "b"]);
1062                assert!(dry_run);
1063                assert_eq!(preset.as_deref(), Some("dev"));
1064            }
1065            other => panic!("expected Start, got {other:?}"),
1066        }
1067    }
1068
1069    // -- Add subcommand --
1070
1071    #[test]
1072    fn add_with_branch_only() {
1073        let cli = parse(&["add", "feat/new"]);
1074        match cli.command.unwrap() {
1075            Command::Add {
1076                branch,
1077                cli,
1078                from_spec,
1079            } => {
1080                assert_eq!(branch.as_deref(), Some("feat/new"));
1081                assert!(cli.is_none());
1082                assert!(from_spec.is_none());
1083            }
1084            other => panic!("expected Add, got {other:?}"),
1085        }
1086    }
1087
1088    #[test]
1089    fn add_with_branch_and_cli() {
1090        let cli = parse(&["add", "feat/x", "--cli", "codex"]);
1091        match cli.command.unwrap() {
1092            Command::Add { branch, cli, .. } => {
1093                assert_eq!(branch.as_deref(), Some("feat/x"));
1094                assert_eq!(cli.as_deref(), Some("codex"));
1095            }
1096            other => panic!("expected Add, got {other:?}"),
1097        }
1098    }
1099
1100    #[test]
1101    fn add_with_from_spec_only_needs_no_branch() {
1102        let cli = parse(&["add", "--from-spec", "add-export"]);
1103        match cli.command.unwrap() {
1104            Command::Add {
1105                branch, from_spec, ..
1106            } => {
1107                assert!(branch.is_none());
1108                assert_eq!(from_spec.as_deref(), Some("add-export"));
1109            }
1110            other => panic!("expected Add, got {other:?}"),
1111        }
1112    }
1113
1114    #[test]
1115    fn add_with_no_branch_and_no_from_spec_is_rejected() {
1116        let result = Cli::try_parse_from(["git-paw", "add"]);
1117        assert!(
1118            result.is_err(),
1119            "add requires either a branch or --from-spec"
1120        );
1121    }
1122
1123    #[test]
1124    fn add_with_branch_and_from_spec_is_rejected() {
1125        let result = Cli::try_parse_from(["git-paw", "add", "feat/x", "--from-spec", "change"]);
1126        assert!(
1127            result.is_err(),
1128            "branch and --from-spec are mutually exclusive"
1129        );
1130    }
1131
1132    #[test]
1133    fn add_help_lists_flags_and_examples() {
1134        let result = Cli::try_parse_from(["git-paw", "add", "--help"]);
1135        let err = result.unwrap_err();
1136        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1137        let help = err.to_string();
1138        assert!(help.contains("--cli"), "got: {help}");
1139        assert!(help.contains("--from-spec"), "got: {help}");
1140        assert!(
1141            help.contains("git paw add feat/api --cli codex"),
1142            "add --help should include copy-pasteable examples; got: {help}"
1143        );
1144    }
1145
1146    // -- Remove subcommand --
1147
1148    #[test]
1149    fn remove_with_branch_only() {
1150        let cli = parse(&["remove", "feat/done"]);
1151        match cli.command.unwrap() {
1152            Command::Remove {
1153                branch,
1154                keep_worktree,
1155                force,
1156            } => {
1157                assert_eq!(branch, "feat/done");
1158                assert!(!keep_worktree);
1159                assert!(!force);
1160            }
1161            other => panic!("expected Remove, got {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn remove_with_keep_worktree_and_force() {
1167        let cli = parse(&["remove", "feat/x", "--keep-worktree", "--force"]);
1168        match cli.command.unwrap() {
1169            Command::Remove {
1170                keep_worktree,
1171                force,
1172                ..
1173            } => {
1174                assert!(keep_worktree);
1175                assert!(force);
1176            }
1177            other => panic!("expected Remove, got {other:?}"),
1178        }
1179    }
1180
1181    #[test]
1182    fn remove_without_branch_is_rejected() {
1183        let result = Cli::try_parse_from(["git-paw", "remove"]);
1184        assert!(result.is_err(), "remove requires a branch");
1185    }
1186
1187    #[test]
1188    fn remove_help_lists_flags_and_examples() {
1189        let result = Cli::try_parse_from(["git-paw", "remove", "--help"]);
1190        let err = result.unwrap_err();
1191        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1192        let help = err.to_string();
1193        assert!(help.contains("--keep-worktree"), "got: {help}");
1194        assert!(help.contains("--force"), "got: {help}");
1195        assert!(
1196            help.contains("git paw remove feat/wip --force"),
1197            "remove --help should include copy-pasteable examples; got: {help}"
1198        );
1199    }
1200
1201    #[test]
1202    fn root_help_lists_add_and_remove() {
1203        let result = Cli::try_parse_from(["git-paw", "--help"]);
1204        let help = result.unwrap_err().to_string();
1205        assert!(
1206            help.contains("add"),
1207            "root help should list add; got: {help}"
1208        );
1209        assert!(
1210            help.contains("remove"),
1211            "root help should list remove; got: {help}"
1212        );
1213    }
1214
1215    // -- Pause subcommand --
1216
1217    #[test]
1218    fn pause_parses() {
1219        let cli = parse(&["pause"]);
1220        assert!(matches!(cli.command.unwrap(), Command::Pause));
1221    }
1222
1223    #[test]
1224    fn pause_help_mentions_ram_tradeoff() {
1225        let result = Cli::try_parse_from(["git-paw", "pause", "--help"]);
1226        assert!(result.is_err());
1227        let err = result.unwrap_err();
1228        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1229        let help = err.to_string();
1230        assert!(
1231            help.to_lowercase().contains("ram"),
1232            "pause --help should mention RAM tradeoff, got: {help}"
1233        );
1234        assert!(
1235            help.contains("stop"),
1236            "pause --help should cross-reference stop, got: {help}"
1237        );
1238    }
1239
1240    #[test]
1241    fn pause_rejects_unknown_flags() {
1242        let result = Cli::try_parse_from(["git-paw", "pause", "--anything"]);
1243        assert!(result.is_err(), "pause should reject unknown flags");
1244    }
1245
1246    #[test]
1247    fn root_help_lists_pause() {
1248        let result = Cli::try_parse_from(["git-paw", "--help"]);
1249        assert!(result.is_err());
1250        let err = result.unwrap_err();
1251        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1252        let help = err.to_string();
1253        assert!(
1254            help.contains("pause"),
1255            "root --help should list pause subcommand, got: {help}"
1256        );
1257        // Quick-start block should also reference pause.
1258        assert!(
1259            help.contains("git paw pause"),
1260            "after_help quick-start should mention `git paw pause`"
1261        );
1262    }
1263
1264    // -- Stop subcommand --
1265
1266    #[test]
1267    fn stop_parses() {
1268        let cli = parse(&["stop"]);
1269        assert!(matches!(
1270            cli.command.unwrap(),
1271            Command::Stop { force: false }
1272        ));
1273    }
1274
1275    #[test]
1276    fn stop_without_force() {
1277        let cli = parse(&["stop"]);
1278        match cli.command.unwrap() {
1279            Command::Stop { force } => assert!(!force),
1280            other => panic!("expected Stop, got {other:?}"),
1281        }
1282    }
1283
1284    #[test]
1285    fn stop_with_force() {
1286        let cli = parse(&["stop", "--force"]);
1287        match cli.command.unwrap() {
1288            Command::Stop { force } => assert!(force),
1289            other => panic!("expected Stop, got {other:?}"),
1290        }
1291    }
1292
1293    #[test]
1294    fn stop_help_mentions_pause_and_purge() {
1295        let result = Cli::try_parse_from(["git-paw", "stop", "--help"]);
1296        assert!(result.is_err());
1297        let err = result.unwrap_err();
1298        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1299        let help = err.to_string();
1300        assert!(
1301            help.contains("pause"),
1302            "stop --help should reference pause, got: {help}"
1303        );
1304        assert!(
1305            help.contains("purge"),
1306            "stop --help should reference purge, got: {help}"
1307        );
1308        assert!(
1309            help.contains("--force"),
1310            "stop --help should list --force, got: {help}"
1311        );
1312    }
1313
1314    // -- Purge subcommand --
1315
1316    #[test]
1317    fn purge_without_force() {
1318        let cli = parse(&["purge"]);
1319        match cli.command.unwrap() {
1320            Command::Purge { force, stale } => {
1321                assert!(!force);
1322                assert!(!stale);
1323            }
1324            other => panic!("expected Purge, got {other:?}"),
1325        }
1326    }
1327
1328    #[test]
1329    fn purge_with_force() {
1330        let cli = parse(&["purge", "--force"]);
1331        match cli.command.unwrap() {
1332            Command::Purge { force, stale } => {
1333                assert!(force);
1334                assert!(!stale);
1335            }
1336            other => panic!("expected Purge, got {other:?}"),
1337        }
1338    }
1339
1340    #[test]
1341    fn purge_with_stale() {
1342        let cli = parse(&["purge", "--stale"]);
1343        match cli.command.unwrap() {
1344            Command::Purge { force, stale } => {
1345                assert!(!force);
1346                assert!(stale);
1347            }
1348            other => panic!("expected Purge, got {other:?}"),
1349        }
1350    }
1351
1352    #[test]
1353    fn purge_with_stale_and_force() {
1354        let cli = parse(&["purge", "--stale", "--force"]);
1355        match cli.command.unwrap() {
1356            Command::Purge { force, stale } => {
1357                assert!(force);
1358                assert!(stale);
1359            }
1360            other => panic!("expected Purge, got {other:?}"),
1361        }
1362    }
1363
1364    // -- Status subcommand --
1365
1366    #[test]
1367    fn status_parses() {
1368        let cli = parse(&["status"]);
1369        match cli.command.unwrap() {
1370            Command::Status { json } => assert!(!json),
1371            other => panic!("expected Status, got {other:?}"),
1372        }
1373    }
1374
1375    #[test]
1376    fn status_with_json() {
1377        let cli = parse(&["status", "--json"]);
1378        match cli.command.unwrap() {
1379            Command::Status { json } => assert!(json),
1380            other => panic!("expected Status, got {other:?}"),
1381        }
1382    }
1383
1384    // -- List-CLIs subcommand --
1385
1386    #[test]
1387    fn list_clis_parses() {
1388        let cli = parse(&["list-clis"]);
1389        assert!(matches!(cli.command.unwrap(), Command::ListClis));
1390    }
1391
1392    // -- Add-CLI subcommand --
1393
1394    #[test]
1395    fn add_cli_with_required_args() {
1396        let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
1397        match cli.command.unwrap() {
1398            Command::AddCli {
1399                name,
1400                command,
1401                display_name,
1402            } => {
1403                assert_eq!(name, "my-agent");
1404                assert_eq!(command, "/usr/local/bin/my-agent");
1405                assert!(display_name.is_none());
1406            }
1407            other => panic!("expected AddCli, got {other:?}"),
1408        }
1409    }
1410
1411    #[test]
1412    fn add_cli_with_display_name() {
1413        let cli = parse(&[
1414            "add-cli",
1415            "my-agent",
1416            "my-agent",
1417            "--display-name",
1418            "My Agent",
1419        ]);
1420        match cli.command.unwrap() {
1421            Command::AddCli {
1422                name,
1423                command,
1424                display_name,
1425            } => {
1426                assert_eq!(name, "my-agent");
1427                assert_eq!(command, "my-agent");
1428                assert_eq!(display_name.as_deref(), Some("My Agent"));
1429            }
1430            other => panic!("expected AddCli, got {other:?}"),
1431        }
1432    }
1433
1434    // -- Remove-CLI subcommand --
1435
1436    #[test]
1437    fn remove_cli_parses() {
1438        let cli = parse(&["remove-cli", "my-agent"]);
1439        match cli.command.unwrap() {
1440            Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
1441            other => panic!("expected RemoveCli, got {other:?}"),
1442        }
1443    }
1444
1445    // -- Help text quality --
1446
1447    #[test]
1448    fn version_flag_is_accepted() {
1449        let result = Cli::try_parse_from(["git-paw", "--version"]);
1450        // clap returns Err(DisplayVersion) for --version, which is expected
1451        assert!(result.is_err());
1452        let err = result.unwrap_err();
1453        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
1454    }
1455
1456    #[test]
1457    fn help_flag_is_accepted() {
1458        let result = Cli::try_parse_from(["git-paw", "--help"]);
1459        assert!(result.is_err());
1460        let err = result.unwrap_err();
1461        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1462    }
1463
1464    // --- Gap #6: init_parses ---
1465
1466    #[test]
1467    fn init_parses() {
1468        let cli = parse(&["init"]);
1469        assert!(matches!(cli.command.unwrap(), Command::Init));
1470    }
1471
1472    // --- Gap #7: init_help_text ---
1473
1474    #[test]
1475    fn init_help_text() {
1476        let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
1477        assert!(result.is_err());
1478        let err = result.unwrap_err();
1479        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1480    }
1481
1482    #[test]
1483    fn unknown_subcommand_is_rejected() {
1484        let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
1485        assert!(result.is_err());
1486    }
1487
1488    #[test]
1489    fn add_cli_missing_required_args_is_rejected() {
1490        let result = Cli::try_parse_from(["git-paw", "add-cli"]);
1491        assert!(result.is_err());
1492    }
1493
1494    // -- Replay subcommand --
1495
1496    #[test]
1497    fn replay_with_branch() {
1498        let cli = parse(&["replay", "feat/add-auth"]);
1499        match cli.command.unwrap() {
1500            Command::Replay {
1501                branch,
1502                list,
1503                color,
1504                session,
1505            } => {
1506                assert_eq!(branch.as_deref(), Some("feat/add-auth"));
1507                assert!(!list);
1508                assert!(!color);
1509                assert!(session.is_none());
1510            }
1511            other => panic!("expected Replay, got {other:?}"),
1512        }
1513    }
1514
1515    #[test]
1516    fn replay_with_list() {
1517        let cli = parse(&["replay", "--list"]);
1518        match cli.command.unwrap() {
1519            Command::Replay { branch, list, .. } => {
1520                assert!(list);
1521                assert!(branch.is_none());
1522            }
1523            other => panic!("expected Replay, got {other:?}"),
1524        }
1525    }
1526
1527    #[test]
1528    fn replay_with_color() {
1529        let cli = parse(&["replay", "feat/add-auth", "--color"]);
1530        match cli.command.unwrap() {
1531            Command::Replay { color, .. } => assert!(color),
1532            other => panic!("expected Replay, got {other:?}"),
1533        }
1534    }
1535
1536    #[test]
1537    fn replay_with_session() {
1538        let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
1539        match cli.command.unwrap() {
1540            Command::Replay { session, .. } => {
1541                assert_eq!(session.as_deref(), Some("paw-myproject"));
1542            }
1543            other => panic!("expected Replay, got {other:?}"),
1544        }
1545    }
1546
1547    #[test]
1548    fn replay_no_args_fails() {
1549        let result = Cli::try_parse_from(["git-paw", "replay"]);
1550        assert!(result.is_err());
1551    }
1552
1553    #[test]
1554    fn replay_help_text() {
1555        let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
1556        assert!(result.is_err());
1557        let err = result.unwrap_err();
1558        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1559        let help = err.to_string();
1560        assert!(help.contains("--list"));
1561        assert!(help.contains("--color"));
1562        assert!(help.contains("--session"));
1563    }
1564
1565    #[test]
1566    fn help_shows_replay_subcommand() {
1567        let result = Cli::try_parse_from(["git-paw", "--help"]);
1568        let err = result.unwrap_err();
1569        let help = err.to_string();
1570        assert!(
1571            help.contains("replay"),
1572            "help should list the replay subcommand"
1573        );
1574    }
1575
1576    // -- __dashboard subcommand --
1577
1578    #[test]
1579    fn dashboard_parses() {
1580        let cli = parse(&["__dashboard"]);
1581        assert!(matches!(cli.command.unwrap(), Command::Dashboard));
1582    }
1583
1584    // -- --no-rebase flag --
1585
1586    #[test]
1587    fn start_with_no_rebase_flag_sets_no_rebase_true() {
1588        let cli = parse(&["start", "--no-rebase"]);
1589        match cli.command.unwrap() {
1590            Command::Start { no_rebase, .. } => assert!(no_rebase),
1591            other => panic!("expected Start, got {other:?}"),
1592        }
1593    }
1594
1595    #[test]
1596    fn start_without_no_rebase_defaults_to_false() {
1597        let cli = parse(&["start"]);
1598        match cli.command.unwrap() {
1599            Command::Start { no_rebase, .. } => assert!(!no_rebase),
1600            other => panic!("expected Start, got {other:?}"),
1601        }
1602    }
1603
1604    #[test]
1605    fn start_no_rebase_combines_with_supervisor() {
1606        let cli = parse(&["start", "--no-rebase", "--supervisor"]);
1607        match cli.command.unwrap() {
1608            Command::Start {
1609                no_rebase,
1610                supervisor,
1611                ..
1612            } => {
1613                assert!(no_rebase);
1614                assert!(supervisor);
1615            }
1616            other => panic!("expected Start, got {other:?}"),
1617        }
1618    }
1619
1620    #[test]
1621    fn start_help_shows_no_rebase_flag() {
1622        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
1623        assert!(result.is_err());
1624        let err = result.unwrap_err();
1625        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1626        let help = err.to_string();
1627        assert!(
1628            help.contains("--no-rebase"),
1629            "start --help should contain --no-rebase, got: {help}"
1630        );
1631    }
1632
1633    // -- Approvals subcommand --
1634
1635    #[test]
1636    fn approvals_parses_with_no_flags() {
1637        let cli = parse(&["approvals"]);
1638        match cli.command.unwrap() {
1639            Command::Approvals {
1640                session,
1641                limit,
1642                json,
1643            } => {
1644                assert!(session.is_none());
1645                assert!(limit.is_none());
1646                assert!(!json);
1647            }
1648            other => panic!("expected Approvals, got {other:?}"),
1649        }
1650    }
1651
1652    #[test]
1653    fn approvals_with_session_limit_and_json() {
1654        let cli = parse(&[
1655            "approvals",
1656            "--session",
1657            "paw-other",
1658            "--limit",
1659            "5",
1660            "--json",
1661        ]);
1662        match cli.command.unwrap() {
1663            Command::Approvals {
1664                session,
1665                limit,
1666                json,
1667            } => {
1668                assert_eq!(session.as_deref(), Some("paw-other"));
1669                assert_eq!(limit, Some(5));
1670                assert!(json);
1671            }
1672            other => panic!("expected Approvals, got {other:?}"),
1673        }
1674    }
1675
1676    #[test]
1677    fn approvals_rejects_non_numeric_limit() {
1678        let result = Cli::try_parse_from(["git-paw", "approvals", "--limit", "lots"]);
1679        assert!(result.is_err());
1680    }
1681
1682    #[test]
1683    fn approvals_help_lists_flags_and_examples() {
1684        let result = Cli::try_parse_from(["git-paw", "approvals", "--help"]);
1685        assert!(result.is_err());
1686        let err = result.unwrap_err();
1687        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1688        let help = err.to_string();
1689        assert!(help.contains("--session"), "got: {help}");
1690        assert!(help.contains("--limit"), "got: {help}");
1691        assert!(help.contains("--json"), "got: {help}");
1692        assert!(
1693            help.contains("git paw approvals --json"),
1694            "help should include examples, got: {help}"
1695        );
1696    }
1697
1698    #[test]
1699    fn help_shows_approvals_subcommand() {
1700        let result = Cli::try_parse_from(["git-paw", "--help"]);
1701        let err = result.unwrap_err();
1702        let help = err.to_string();
1703        assert!(
1704            help.contains("approvals"),
1705            "root help should list approvals subcommand, got: {help}"
1706        );
1707    }
1708
1709    // -- Mcp subcommand --
1710
1711    #[test]
1712    fn mcp_parses_with_no_flags() {
1713        let cli = parse(&["mcp"]);
1714        match cli.command.unwrap() {
1715            Command::Mcp { repo, log_file } => {
1716                assert!(repo.is_none());
1717                assert!(log_file.is_none());
1718            }
1719            other => panic!("expected Mcp, got {other:?}"),
1720        }
1721    }
1722
1723    #[test]
1724    fn mcp_parses_with_repo() {
1725        let cli = parse(&["mcp", "--repo", "/path/to/repo"]);
1726        match cli.command.unwrap() {
1727            Command::Mcp { repo, .. } => {
1728                assert_eq!(repo.as_deref(), Some(std::path::Path::new("/path/to/repo")));
1729            }
1730            other => panic!("expected Mcp, got {other:?}"),
1731        }
1732    }
1733
1734    #[test]
1735    fn mcp_parses_with_repo_and_log_file() {
1736        let cli = parse(&["mcp", "--repo", "/r", "--log-file", "/tmp/mcp.log"]);
1737        match cli.command.unwrap() {
1738            Command::Mcp { repo, log_file } => {
1739                assert_eq!(repo.as_deref(), Some(std::path::Path::new("/r")));
1740                assert_eq!(
1741                    log_file.as_deref(),
1742                    Some(std::path::Path::new("/tmp/mcp.log"))
1743                );
1744            }
1745            other => panic!("expected Mcp, got {other:?}"),
1746        }
1747    }
1748
1749    #[test]
1750    fn mcp_rejects_daemon_and_http_flags() {
1751        // v0.7.0 ships stdio only — no --port / --host / --daemon / start / stop / status.
1752        for bad in [
1753            vec!["mcp", "--port", "9119"],
1754            vec!["mcp", "--host", "127.0.0.1"],
1755            vec!["mcp", "--daemon"],
1756            vec!["mcp", "start"],
1757            vec!["mcp", "stop"],
1758            vec!["mcp", "status"],
1759        ] {
1760            let mut full = vec!["git-paw"];
1761            full.extend(bad.iter().copied());
1762            assert!(
1763                Cli::try_parse_from(&full).is_err(),
1764                "mcp should reject {bad:?} in v0.7.0"
1765            );
1766        }
1767    }
1768
1769    #[test]
1770    fn mcp_help_describes_supported_flags_and_config_snippet() {
1771        let result = Cli::try_parse_from(["git-paw", "mcp", "--help"]);
1772        assert!(result.is_err());
1773        let err = result.unwrap_err();
1774        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1775        let help = err.to_string();
1776        assert!(
1777            help.contains("--repo"),
1778            "mcp --help should list --repo; got: {help}"
1779        );
1780        assert!(
1781            help.contains("--log-file"),
1782            "mcp --help should list --log-file; got: {help}"
1783        );
1784        assert!(
1785            help.contains("mcpServers"),
1786            "mcp --help should include a copy-pasteable Claude Desktop config snippet; got: {help}"
1787        );
1788        for forbidden in ["--port", "--host", "--daemon"] {
1789            assert!(
1790                !help.contains(forbidden),
1791                "mcp --help must not advertise {forbidden}; got: {help}"
1792            );
1793        }
1794    }
1795
1796    #[test]
1797    fn help_shows_mcp_subcommand() {
1798        let result = Cli::try_parse_from(["git-paw", "--help"]);
1799        let err = result.unwrap_err();
1800        let help = err.to_string();
1801        assert!(
1802            help.contains("mcp"),
1803            "root help should list the mcp subcommand, got: {help}"
1804        );
1805    }
1806
1807    #[test]
1808    fn dashboard_does_not_appear_in_help() {
1809        let result = Cli::try_parse_from(["git-paw", "--help"]);
1810        let err = result.unwrap_err();
1811        let help = err.to_string();
1812        assert!(
1813            !help.contains("__dashboard"),
1814            "hidden __dashboard subcommand should not appear in help output"
1815        );
1816    }
1817}