Skip to main content

git_paw/
cli.rs

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