Skip to main content

batty_cli/
cli.rs

1use std::path::PathBuf;
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5#[derive(Parser, Debug)]
6#[command(
7    name = "batty",
8    about = "Hierarchical agent team system for software development",
9    version = concat!(env!("CARGO_PKG_VERSION"), "\nhttps://github.com/battysh/batty")
10)]
11pub struct Cli {
12    #[command(subcommand)]
13    pub command: Command,
14
15    /// Verbosity level (-v, -vv, -vvv)
16    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
17    pub verbose: u8,
18}
19
20#[derive(Subcommand, Debug)]
21pub enum Command {
22    /// Scaffold .batty/team_config/ with default team.yaml and prompt templates
23    #[command(alias = "install")]
24    Init {
25        /// Template to use for scaffolding
26        #[arg(long, value_enum, conflicts_with = "from")]
27        template: Option<InitTemplate>,
28        /// Copy team config from $HOME/.batty/templates/<name>/
29        #[arg(long, conflicts_with = "template")]
30        from: Option<String>,
31        /// Overwrite existing team config files
32        #[arg(long)]
33        force: bool,
34        /// Default agent backend for all roles (claude, codex, kiro)
35        #[arg(long)]
36        agent: Option<String>,
37    },
38
39    /// Export the current team config as a reusable template
40    ExportTemplate {
41        /// Template name
42        name: String,
43    },
44
45    /// Export run state for debugging
46    ExportRun,
47
48    /// Generate a run retrospective
49    Retro {
50        /// Path to events.jsonl (default: .batty/team_config/events.jsonl)
51        #[arg(long)]
52        events: Option<PathBuf>,
53    },
54
55    /// Start the team daemon and tmux session
56    Start {
57        /// Auto-attach to the tmux session after startup
58        #[arg(long, default_value_t = false)]
59        attach: bool,
60    },
61
62    /// Stop the team daemon and kill the tmux session
63    Stop,
64
65    /// Attach to the running team tmux session
66    Attach,
67
68    /// Show all team members and their states
69    Status {
70        /// Emit machine-readable JSON output
71        #[arg(long, default_value_t = false)]
72        json: bool,
73    },
74
75    /// Send a message to an agent role (human → agent injection)
76    Send {
77        /// Explicit sender override (hidden; used by pane bridge and automation)
78        #[arg(long, hide = true)]
79        from: Option<String>,
80        /// Target role name (e.g., "architect", "manager-1")
81        role: String,
82        /// Message to inject
83        message: String,
84    },
85
86    /// Assign a task to an engineer (used by manager agent)
87    Assign {
88        /// Target engineer instance (e.g., "eng-1-1")
89        engineer: String,
90        /// Task description
91        task: String,
92    },
93
94    /// Validate team config without launching
95    Validate {
96        /// Show all individual checks with pass/fail status
97        #[arg(long, default_value_t = false)]
98        show_checks: bool,
99    },
100
101    /// Show resolved team configuration
102    Config {
103        /// Emit machine-readable JSON output
104        #[arg(long, default_value_t = false)]
105        json: bool,
106    },
107
108    /// Show the kanban board
109    Board {
110        #[command(subcommand)]
111        command: Option<BoardCommand>,
112    },
113
114    /// List inbox messages for a team member, or purge delivered inbox messages
115    #[command(args_conflicts_with_subcommands = true)]
116    Inbox {
117        #[command(subcommand)]
118        command: Option<InboxCommand>,
119        /// Member name (e.g., "architect", "manager-1", "eng-1-1")
120        member: Option<String>,
121        /// Maximum number of recent messages to show
122        #[arg(
123            short = 'n',
124            long = "limit",
125            default_value_t = 20,
126            conflicts_with = "all"
127        )]
128        limit: usize,
129        /// Show all messages
130        #[arg(long, default_value_t = false)]
131        all: bool,
132    },
133
134    /// Read a specific message from a member's inbox
135    Read {
136        /// Member name
137        member: String,
138        /// Message REF, ID, or ID prefix from `batty inbox` output
139        id: String,
140    },
141
142    /// Acknowledge (mark delivered) a message in a member's inbox
143    Ack {
144        /// Member name
145        member: String,
146        /// Message REF, ID, or ID prefix from `batty inbox` output
147        id: String,
148    },
149
150    /// Merge an engineer's worktree branch into main
151    Merge {
152        /// Engineer instance name (e.g., "eng-1-1")
153        engineer: String,
154    },
155
156    /// Manage workflow task state and metadata
157    Task {
158        #[command(subcommand)]
159        command: TaskCommand,
160    },
161
162    /// Record a structured review disposition for a task
163    Review {
164        /// Task id
165        task_id: u32,
166        /// Review disposition
167        #[arg(value_enum)]
168        disposition: ReviewAction,
169        /// Feedback text
170        feedback: Option<String>,
171        /// Reviewer name (default: human)
172        #[arg(long, default_value = "human")]
173        reviewer: String,
174    },
175
176    /// Generate shell completions
177    Completions {
178        /// Shell to generate completion script for
179        #[arg(value_enum)]
180        shell: CompletionShell,
181    },
182
183    /// Per-intervention runtime toggles
184    Nudge {
185        #[command(subcommand)]
186        command: NudgeCommand,
187    },
188
189    /// Pause nudges and standups
190    Pause,
191
192    /// Resume nudges and standups
193    Resume,
194
195    /// Manage Grafana monitoring (setup, status, open)
196    Grafana {
197        #[command(subcommand)]
198        command: GrafanaCommand,
199    },
200
201    /// Set up Telegram bot for human communication
202    Telegram,
203
204    /// Estimate team load and show recent load history
205    Load,
206
207    /// Show clean-room parity summary from PARITY.md
208    Parity {
209        /// Show the full parity table
210        #[arg(long, default_value_t = false, conflicts_with = "gaps")]
211        detail: bool,
212        /// Show only behaviors with missing tests or implementation
213        #[arg(long, default_value_t = false)]
214        gaps: bool,
215    },
216
217    /// Show pending dispatch queue entries
218    Queue,
219
220    /// Estimate current run cost from agent session files
221    Cost,
222
223    /// Dynamically scale team topology (add/remove agents)
224    Scale {
225        #[command(subcommand)]
226        command: ScaleCommand,
227    },
228
229    /// Dump diagnostic state from Batty state files
230    Doctor {
231        /// Remove orphan branches and worktrees after confirmation
232        #[arg(long, default_value_t = false)]
233        fix: bool,
234        /// Skip the cleanup confirmation prompt
235        #[arg(long, default_value_t = false, requires = "fix")]
236        yes: bool,
237    },
238
239    /// Show consolidated telemetry dashboard (tasks, cycle time, rates, agents)
240    Metrics,
241
242    /// Query the telemetry database for agent and task metrics
243    Telemetry {
244        #[command(subcommand)]
245        command: TelemetryCommand,
246    },
247
248    /// Interactive chat with an agent via the shim protocol
249    Chat {
250        /// Agent type: claude, codex, kiro, generic
251        #[arg(long, default_value = "generic")]
252        agent_type: String,
253
254        /// Shell command to launch the agent CLI (auto-detected from agent type if omitted)
255        #[arg(long)]
256        cmd: Option<String>,
257
258        /// Working directory for the agent
259        #[arg(long, default_value = ".")]
260        cwd: String,
261
262        /// Use SDK mode (NDJSON stdin/stdout) instead of PTY screen-scraping
263        #[arg(long, default_value_t = false)]
264        sdk_mode: bool,
265    },
266
267    /// Internal: run a shim process (spawned by `batty chat` or orchestrator)
268    #[command(hide = true)]
269    Shim {
270        /// Unique agent identifier
271        #[arg(long)]
272        id: String,
273
274        /// Agent type: claude, codex, kiro, generic
275        #[arg(long)]
276        agent_type: String,
277
278        /// Shell command to launch the agent CLI
279        #[arg(long)]
280        cmd: String,
281
282        /// Working directory for the agent
283        #[arg(long)]
284        cwd: String,
285
286        /// Terminal rows
287        #[arg(long, default_value = "50")]
288        rows: u16,
289
290        /// Terminal columns
291        #[arg(long, default_value = "220")]
292        cols: u16,
293
294        /// Path to write raw PTY output for tmux display panes
295        #[arg(long)]
296        pty_log_path: Option<String>,
297
298        /// Seconds to wait for auto-commit before force-killing.
299        #[arg(long, default_value_t = 5)]
300        graceful_shutdown_timeout_secs: u64,
301
302        /// Auto-commit dirty worktrees before restart/shutdown.
303        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
304        auto_commit_on_restart: bool,
305
306        /// Use SDK mode (NDJSON stdin/stdout) instead of PTY screen-scraping
307        #[arg(long, default_value_t = false)]
308        sdk_mode: bool,
309    },
310
311    /// Internal: interactive shim pane bridge for tmux
312    #[command(hide = true)]
313    ConsolePane {
314        /// Project root directory
315        #[arg(long)]
316        project_root: String,
317
318        /// Member/agent id
319        #[arg(long)]
320        member: String,
321
322        /// Path to the shim event log
323        #[arg(long)]
324        events_log_path: String,
325
326        /// Path to the shim PTY log
327        #[arg(long)]
328        pty_log_path: String,
329    },
330
331    /// Internal: run the daemon loop (spawned by `batty start`)
332    #[command(hide = true)]
333    Daemon {
334        /// Project root directory
335        #[arg(long)]
336        project_root: String,
337        /// Resume agent sessions from a previous run
338        #[arg(long)]
339        resume: bool,
340    },
341}
342
343#[derive(Subcommand, Debug)]
344pub enum TelemetryCommand {
345    /// Show session summaries
346    Summary,
347    /// Show per-agent performance metrics
348    Agents,
349    /// Show per-task lifecycle metrics
350    Tasks,
351    /// Show review pipeline metrics (auto-merge rate, rework, latency)
352    Reviews,
353    /// Show recent events from the telemetry database
354    Events {
355        /// Maximum number of events to show
356        #[arg(short = 'n', long = "limit", default_value_t = 50)]
357        limit: usize,
358    },
359}
360
361#[derive(Subcommand, Debug)]
362pub enum GrafanaCommand {
363    /// Install Grafana and the SQLite datasource plugin, then start the service
364    Setup,
365    /// Check whether the Grafana server is reachable
366    Status,
367    /// Open the Grafana dashboard in the default browser
368    Open,
369}
370
371#[derive(Subcommand, Debug)]
372pub enum InboxCommand {
373    /// Purge delivered messages from inbox cur/ directories
374    Purge {
375        /// Role/member name to purge
376        #[arg(required_unless_present = "all_roles")]
377        role: Option<String>,
378        /// Purge delivered messages for every inbox
379        #[arg(long, default_value_t = false)]
380        all_roles: bool,
381        /// Purge delivered messages older than this unix timestamp
382        #[arg(long, conflicts_with_all = ["all", "older_than"])]
383        before: Option<u64>,
384        /// Purge delivered messages older than this duration (e.g. 24h, 7d, 2w)
385        #[arg(long, conflicts_with_all = ["all", "before"])]
386        older_than: Option<String>,
387        /// Purge all delivered messages
388        #[arg(long, default_value_t = false, conflicts_with_all = ["before", "older_than"])]
389        all: bool,
390    },
391}
392
393#[derive(Subcommand, Debug)]
394pub enum BoardCommand {
395    /// List board tasks in a non-interactive table
396    List {
397        /// Filter tasks by status
398        #[arg(long)]
399        status: Option<String>,
400    },
401    /// Show per-status task counts
402    Summary,
403    /// Show dependency graph
404    Deps {
405        /// Output format: tree (default), flat, or dot
406        #[arg(long, value_enum, default_value_t = DepsFormatArg::Tree)]
407        format: DepsFormatArg,
408    },
409    /// Move done tasks to archive directory
410    Archive {
411        /// Only archive tasks older than this (e.g. "7d", "24h", "2w", or ISO date)
412        #[arg(long, default_value = "0s")]
413        older_than: String,
414
415        /// Show what would be archived without moving files
416        #[arg(long)]
417        dry_run: bool,
418    },
419    /// Show board health dashboard
420    Health,
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
424pub enum DepsFormatArg {
425    Tree,
426    Flat,
427    Dot,
428}
429
430#[derive(Subcommand, Debug)]
431pub enum TaskCommand {
432    /// Transition a task to a new workflow state
433    Transition {
434        /// Task id
435        task_id: u32,
436        /// Target state
437        #[arg(value_enum)]
438        target_state: TaskStateArg,
439    },
440
441    /// Assign execution and/or review ownership
442    Assign {
443        /// Task id
444        task_id: u32,
445        /// Execution owner
446        #[arg(long = "execution-owner")]
447        execution_owner: Option<String>,
448        /// Review owner
449        #[arg(long = "review-owner")]
450        review_owner: Option<String>,
451    },
452
453    /// Record a review disposition for a task
454    Review {
455        /// Task id
456        task_id: u32,
457        /// Review disposition
458        #[arg(long, value_enum)]
459        disposition: ReviewDispositionArg,
460        /// Feedback text (stored and delivered for changes_requested)
461        #[arg(long)]
462        feedback: Option<String>,
463    },
464
465    /// Update workflow metadata fields
466    Update {
467        /// Task id
468        task_id: u32,
469        /// Worktree branch
470        #[arg(long)]
471        branch: Option<String>,
472        /// Commit sha
473        #[arg(long)]
474        commit: Option<String>,
475        /// Blocking reason
476        #[arg(long = "blocked-on")]
477        blocked_on: Option<String>,
478        /// Clear blocking fields
479        #[arg(long = "clear-blocked", default_value_t = false)]
480        clear_blocked: bool,
481    },
482
483    /// Set per-task auto-merge override
484    #[command(name = "auto-merge")]
485    AutoMerge {
486        /// Task id
487        task_id: u32,
488        /// Enable or disable auto-merge for this task
489        #[arg(value_enum)]
490        action: AutoMergeAction,
491    },
492
493    /// Set scheduled_for and/or cron_schedule on a task
494    Schedule {
495        /// Task id
496        task_id: u32,
497        /// Scheduled datetime in RFC3339 format (e.g. 2026-03-25T09:00:00-04:00)
498        #[arg(long = "at")]
499        at: Option<String>,
500        /// Cron expression (e.g. '0 9 * * *')
501        #[arg(long = "cron")]
502        cron: Option<String>,
503        /// Clear both scheduled_for and cron_schedule
504        #[arg(long, default_value_t = false)]
505        clear: bool,
506    },
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
510pub enum InitTemplate {
511    /// Single agent, no hierarchy (1 pane)
512    Solo,
513    /// Architect + 1 engineer pair (2 panes)
514    Pair,
515    /// 1 architect + 1 manager + 3 engineers (5 panes)
516    Simple,
517    /// 1 architect + 1 manager + 5 engineers with layout (7 panes)
518    Squad,
519    /// Human + architect + 3 managers + 15 engineers with Telegram (19 panes)
520    Large,
521    /// PI + 3 sub-leads + 6 researchers — research lab style (10 panes)
522    Research,
523    /// Human + tech lead + 2 eng managers + 8 developers — full product team (11 panes)
524    Software,
525    /// Clean-room workflow: decompiler + spec-writer + test-writer + implementer (4 panes)
526    Cleanroom,
527    /// Batty self-development: human + architect + manager + 4 Rust engineers (6 panes)
528    Batty,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
532pub enum CompletionShell {
533    Bash,
534    Zsh,
535    Fish,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
539pub enum TaskStateArg {
540    Backlog,
541    Todo,
542    #[value(name = "in-progress")]
543    InProgress,
544    Review,
545    Blocked,
546    Done,
547    Archived,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
551pub enum ReviewDispositionArg {
552    Approved,
553    #[value(name = "changes_requested")]
554    ChangesRequested,
555    Rejected,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
559pub enum ReviewAction {
560    Approve,
561    #[value(name = "request-changes")]
562    RequestChanges,
563    Reject,
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
567pub enum AutoMergeAction {
568    Enable,
569    Disable,
570}
571
572#[derive(Subcommand, Debug)]
573pub enum ScaleCommand {
574    /// Set engineer instance count (scales up or down)
575    Engineers {
576        /// Target number of engineers per manager
577        count: u32,
578    },
579    /// Add a new manager role
580    AddManager {
581        /// Name for the new manager role
582        name: String,
583    },
584    /// Remove a manager role
585    RemoveManager {
586        /// Name of the manager role to remove
587        name: String,
588    },
589    /// Show current topology (instance counts)
590    Status,
591}
592
593#[derive(Subcommand, Debug)]
594pub enum NudgeCommand {
595    /// Disable an intervention at runtime
596    Disable {
597        /// Intervention name
598        #[arg(value_enum)]
599        name: NudgeIntervention,
600    },
601    /// Re-enable a disabled intervention
602    Enable {
603        /// Intervention name
604        #[arg(value_enum)]
605        name: NudgeIntervention,
606    },
607    /// Show status of all interventions
608    Status,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
612pub enum NudgeIntervention {
613    Replenish,
614    Triage,
615    Review,
616    Dispatch,
617    Utilization,
618    #[value(name = "owned-task")]
619    OwnedTask,
620}
621
622impl NudgeIntervention {
623    /// Return the marker file suffix for this intervention.
624    #[allow(dead_code)]
625    pub fn marker_name(self) -> &'static str {
626        match self {
627            Self::Replenish => "replenish",
628            Self::Triage => "triage",
629            Self::Review => "review",
630            Self::Dispatch => "dispatch",
631            Self::Utilization => "utilization",
632            Self::OwnedTask => "owned-task",
633        }
634    }
635
636    /// All known interventions.
637    #[allow(dead_code)]
638    pub const ALL: [NudgeIntervention; 6] = [
639        Self::Replenish,
640        Self::Triage,
641        Self::Review,
642        Self::Dispatch,
643        Self::Utilization,
644        Self::OwnedTask,
645    ];
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn board_command_defaults_to_tui() {
654        let cli = Cli::parse_from(["batty", "board"]);
655        match cli.command {
656            Command::Board { command } => assert!(command.is_none()),
657            other => panic!("expected board command, got {other:?}"),
658        }
659    }
660
661    #[test]
662    fn board_list_subcommand_parses() {
663        let cli = Cli::parse_from(["batty", "board", "list"]);
664        match cli.command {
665            Command::Board {
666                command: Some(BoardCommand::List { status }),
667            } => assert_eq!(status, None),
668            other => panic!("expected board list command, got {other:?}"),
669        }
670    }
671
672    #[test]
673    fn board_list_subcommand_parses_status_filter() {
674        let cli = Cli::parse_from(["batty", "board", "list", "--status", "review"]);
675        match cli.command {
676            Command::Board {
677                command: Some(BoardCommand::List { status }),
678            } => assert_eq!(status.as_deref(), Some("review")),
679            other => panic!("expected board list command, got {other:?}"),
680        }
681    }
682
683    #[test]
684    fn board_summary_subcommand_parses() {
685        let cli = Cli::parse_from(["batty", "board", "summary"]);
686        match cli.command {
687            Command::Board {
688                command: Some(BoardCommand::Summary),
689            } => {}
690            other => panic!("expected board summary command, got {other:?}"),
691        }
692    }
693
694    #[test]
695    fn board_deps_subcommand_defaults_to_tree() {
696        let cli = Cli::parse_from(["batty", "board", "deps"]);
697        match cli.command {
698            Command::Board {
699                command: Some(BoardCommand::Deps { format }),
700            } => assert_eq!(format, DepsFormatArg::Tree),
701            other => panic!("expected board deps command, got {other:?}"),
702        }
703    }
704
705    #[test]
706    fn board_deps_subcommand_parses_format_flag() {
707        for (arg, expected) in [
708            ("tree", DepsFormatArg::Tree),
709            ("flat", DepsFormatArg::Flat),
710            ("dot", DepsFormatArg::Dot),
711        ] {
712            let cli = Cli::parse_from(["batty", "board", "deps", "--format", arg]);
713            match cli.command {
714                Command::Board {
715                    command: Some(BoardCommand::Deps { format }),
716                } => assert_eq!(format, expected, "format arg={arg}"),
717                other => panic!("expected board deps command for {arg}, got {other:?}"),
718            }
719        }
720    }
721
722    #[test]
723    fn board_archive_subcommand_parses() {
724        let cli = Cli::parse_from(["batty", "board", "archive"]);
725        match cli.command {
726            Command::Board {
727                command:
728                    Some(BoardCommand::Archive {
729                        older_than,
730                        dry_run,
731                    }),
732            } => {
733                assert_eq!(older_than, "0s");
734                assert!(!dry_run);
735            }
736            other => panic!("expected board archive command, got {other:?}"),
737        }
738    }
739
740    #[test]
741    fn board_archive_subcommand_parses_older_than() {
742        let cli = Cli::parse_from(["batty", "board", "archive", "--older-than", "7d"]);
743        match cli.command {
744            Command::Board {
745                command:
746                    Some(BoardCommand::Archive {
747                        older_than,
748                        dry_run,
749                    }),
750            } => {
751                assert_eq!(older_than, "7d");
752                assert!(!dry_run);
753            }
754            other => panic!("expected board archive command with older_than, got {other:?}"),
755        }
756    }
757
758    #[test]
759    fn board_archive_subcommand_parses_dry_run() {
760        let cli = Cli::parse_from(["batty", "board", "archive", "--dry-run"]);
761        match cli.command {
762            Command::Board {
763                command:
764                    Some(BoardCommand::Archive {
765                        older_than,
766                        dry_run,
767                    }),
768            } => {
769                assert_eq!(older_than, "0s");
770                assert!(dry_run);
771            }
772            other => panic!("expected board archive command with dry_run, got {other:?}"),
773        }
774    }
775
776    #[test]
777    fn board_health_subcommand_parses() {
778        let cli = Cli::parse_from(["batty", "board", "health"]);
779        match cli.command {
780            Command::Board {
781                command: Some(BoardCommand::Health),
782            } => {}
783            other => panic!("expected board health command, got {other:?}"),
784        }
785    }
786
787    #[test]
788    fn init_subcommand_defaults_to_simple() {
789        let cli = Cli::parse_from(["batty", "init"]);
790        match cli.command {
791            Command::Init {
792                template,
793                from,
794                agent,
795                ..
796            } => {
797                assert_eq!(template, None);
798                assert_eq!(from, None);
799                assert_eq!(agent, None);
800            }
801            other => panic!("expected init command, got {other:?}"),
802        }
803    }
804
805    #[test]
806    fn init_subcommand_accepts_large_template() {
807        let cli = Cli::parse_from(["batty", "init", "--template", "large"]);
808        match cli.command {
809            Command::Init { template, from, .. } => {
810                assert_eq!(template, Some(InitTemplate::Large));
811                assert_eq!(from, None);
812            }
813            other => panic!("expected init command, got {other:?}"),
814        }
815    }
816
817    #[test]
818    fn init_subcommand_accepts_from_template_name() {
819        let cli = Cli::parse_from(["batty", "init", "--from", "custom-team"]);
820        match cli.command {
821            Command::Init { template, from, .. } => {
822                assert_eq!(template, None);
823                assert_eq!(from.as_deref(), Some("custom-team"));
824            }
825            other => panic!("expected init command, got {other:?}"),
826        }
827    }
828
829    #[test]
830    fn init_subcommand_rejects_from_with_template() {
831        let result = Cli::try_parse_from(["batty", "init", "--template", "large", "--from", "x"]);
832        assert!(result.is_err());
833    }
834
835    #[test]
836    fn init_agent_flag_parses() {
837        let cli = Cli::parse_from(["batty", "init", "--agent", "codex"]);
838        match cli.command {
839            Command::Init { agent, .. } => {
840                assert_eq!(agent.as_deref(), Some("codex"));
841            }
842            other => panic!("expected init command, got {other:?}"),
843        }
844    }
845
846    #[test]
847    fn install_alias_maps_to_init() {
848        let cli = Cli::parse_from(["batty", "install"]);
849        match cli.command {
850            Command::Init {
851                template,
852                from,
853                agent,
854                ..
855            } => {
856                assert_eq!(template, None);
857                assert_eq!(from, None);
858                assert_eq!(agent, None);
859            }
860            other => panic!("expected init command via install alias, got {other:?}"),
861        }
862    }
863
864    #[test]
865    fn install_alias_with_agent_flag() {
866        let cli = Cli::parse_from(["batty", "install", "--agent", "kiro"]);
867        match cli.command {
868            Command::Init { agent, .. } => {
869                assert_eq!(agent.as_deref(), Some("kiro"));
870            }
871            other => panic!("expected init command via install alias, got {other:?}"),
872        }
873    }
874
875    #[test]
876    fn export_template_subcommand_parses() {
877        let cli = Cli::parse_from(["batty", "export-template", "myteam"]);
878        match cli.command {
879            Command::ExportTemplate { name } => assert_eq!(name, "myteam"),
880            other => panic!("expected export-template command, got {other:?}"),
881        }
882    }
883
884    #[test]
885    fn export_run_subcommand_parses() {
886        let cli = Cli::parse_from(["batty", "export-run"]);
887        match cli.command {
888            Command::ExportRun => {}
889            other => panic!("expected export-run command, got {other:?}"),
890        }
891    }
892
893    #[test]
894    fn retro_subcommand_parses() {
895        let cli = Cli::parse_from(["batty", "retro"]);
896        match cli.command {
897            Command::Retro { events } => assert!(events.is_none()),
898            other => panic!("expected retro command, got {other:?}"),
899        }
900    }
901
902    #[test]
903    fn retro_subcommand_parses_with_events_path() {
904        let cli = Cli::parse_from(["batty", "retro", "--events", "/tmp/events.jsonl"]);
905        match cli.command {
906            Command::Retro { events } => {
907                assert_eq!(events, Some(PathBuf::from("/tmp/events.jsonl")));
908            }
909            other => panic!("expected retro command, got {other:?}"),
910        }
911    }
912
913    #[test]
914    fn start_subcommand_defaults() {
915        let cli = Cli::parse_from(["batty", "start"]);
916        match cli.command {
917            Command::Start { attach } => assert!(!attach),
918            other => panic!("expected start command, got {other:?}"),
919        }
920    }
921
922    #[test]
923    fn start_subcommand_with_attach() {
924        let cli = Cli::parse_from(["batty", "start", "--attach"]);
925        match cli.command {
926            Command::Start { attach } => assert!(attach),
927            other => panic!("expected start command, got {other:?}"),
928        }
929    }
930
931    #[test]
932    fn stop_subcommand_parses() {
933        let cli = Cli::parse_from(["batty", "stop"]);
934        assert!(matches!(cli.command, Command::Stop));
935    }
936
937    #[test]
938    fn attach_subcommand_parses() {
939        let cli = Cli::parse_from(["batty", "attach"]);
940        assert!(matches!(cli.command, Command::Attach));
941    }
942
943    #[test]
944    fn status_subcommand_defaults() {
945        let cli = Cli::parse_from(["batty", "status"]);
946        match cli.command {
947            Command::Status { json } => assert!(!json),
948            other => panic!("expected status command, got {other:?}"),
949        }
950    }
951
952    #[test]
953    fn status_subcommand_json_flag() {
954        let cli = Cli::parse_from(["batty", "status", "--json"]);
955        match cli.command {
956            Command::Status { json } => assert!(json),
957            other => panic!("expected status command, got {other:?}"),
958        }
959    }
960
961    #[test]
962    fn send_subcommand_parses_role_and_message() {
963        let cli = Cli::parse_from(["batty", "send", "architect", "hello world"]);
964        match cli.command {
965            Command::Send {
966                from,
967                role,
968                message,
969            } => {
970                assert!(from.is_none());
971                assert_eq!(role, "architect");
972                assert_eq!(message, "hello world");
973            }
974            other => panic!("expected send command, got {other:?}"),
975        }
976    }
977
978    #[test]
979    fn assign_subcommand_parses_engineer_and_task() {
980        let cli = Cli::parse_from(["batty", "assign", "eng-1-1", "fix auth bug"]);
981        match cli.command {
982            Command::Assign { engineer, task } => {
983                assert_eq!(engineer, "eng-1-1");
984                assert_eq!(task, "fix auth bug");
985            }
986            other => panic!("expected assign command, got {other:?}"),
987        }
988    }
989
990    #[test]
991    fn validate_subcommand_parses() {
992        let cli = Cli::parse_from(["batty", "validate"]);
993        match cli.command {
994            Command::Validate { show_checks } => assert!(!show_checks),
995            other => panic!("expected validate command, got {other:?}"),
996        }
997    }
998
999    #[test]
1000    fn validate_subcommand_show_checks_flag() {
1001        let cli = Cli::parse_from(["batty", "validate", "--show-checks"]);
1002        match cli.command {
1003            Command::Validate { show_checks } => assert!(show_checks),
1004            other => panic!("expected validate command with show_checks, got {other:?}"),
1005        }
1006    }
1007
1008    #[test]
1009    fn config_subcommand_json_flag() {
1010        let cli = Cli::parse_from(["batty", "config", "--json"]);
1011        match cli.command {
1012            Command::Config { json } => assert!(json),
1013            other => panic!("expected config command, got {other:?}"),
1014        }
1015    }
1016
1017    #[test]
1018    fn merge_subcommand_parses_engineer() {
1019        let cli = Cli::parse_from(["batty", "merge", "eng-1-1"]);
1020        match cli.command {
1021            Command::Merge { engineer } => assert_eq!(engineer, "eng-1-1"),
1022            other => panic!("expected merge command, got {other:?}"),
1023        }
1024    }
1025
1026    #[test]
1027    fn completions_subcommand_parses_shell() {
1028        let cli = Cli::parse_from(["batty", "completions", "zsh"]);
1029        match cli.command {
1030            Command::Completions { shell } => assert_eq!(shell, CompletionShell::Zsh),
1031            other => panic!("expected completions command, got {other:?}"),
1032        }
1033    }
1034
1035    #[test]
1036    fn inbox_subcommand_parses_defaults() {
1037        let cli = Cli::parse_from(["batty", "inbox", "architect"]);
1038        match cli.command {
1039            Command::Inbox {
1040                command,
1041                member,
1042                limit,
1043                all,
1044            } => {
1045                assert!(command.is_none());
1046                assert_eq!(member.as_deref(), Some("architect"));
1047                assert_eq!(limit, 20);
1048                assert!(!all);
1049            }
1050            other => panic!("expected inbox command, got {other:?}"),
1051        }
1052    }
1053
1054    #[test]
1055    fn inbox_subcommand_parses_limit_flag() {
1056        let cli = Cli::parse_from(["batty", "inbox", "architect", "-n", "50"]);
1057        match cli.command {
1058            Command::Inbox {
1059                command,
1060                member,
1061                limit,
1062                all,
1063            } => {
1064                assert!(command.is_none());
1065                assert_eq!(member.as_deref(), Some("architect"));
1066                assert_eq!(limit, 50);
1067                assert!(!all);
1068            }
1069            other => panic!("expected inbox command, got {other:?}"),
1070        }
1071    }
1072
1073    #[test]
1074    fn inbox_subcommand_parses_all_flag() {
1075        let cli = Cli::parse_from(["batty", "inbox", "architect", "--all"]);
1076        match cli.command {
1077            Command::Inbox {
1078                command,
1079                member,
1080                limit,
1081                all,
1082            } => {
1083                assert!(command.is_none());
1084                assert_eq!(member.as_deref(), Some("architect"));
1085                assert_eq!(limit, 20);
1086                assert!(all);
1087            }
1088            other => panic!("expected inbox command, got {other:?}"),
1089        }
1090    }
1091
1092    #[test]
1093    fn inbox_purge_subcommand_parses_role_and_before() {
1094        let cli = Cli::parse_from(["batty", "inbox", "purge", "architect", "--before", "123"]);
1095        match cli.command {
1096            Command::Inbox {
1097                command:
1098                    Some(InboxCommand::Purge {
1099                        role,
1100                        all_roles,
1101                        before,
1102                        older_than,
1103                        all,
1104                    }),
1105                member,
1106                ..
1107            } => {
1108                assert!(member.is_none());
1109                assert_eq!(role.as_deref(), Some("architect"));
1110                assert!(!all_roles);
1111                assert_eq!(before, Some(123));
1112                assert!(older_than.is_none());
1113                assert!(!all);
1114            }
1115            other => panic!("expected inbox purge command, got {other:?}"),
1116        }
1117    }
1118
1119    #[test]
1120    fn inbox_purge_subcommand_parses_all_roles_and_all() {
1121        let cli = Cli::parse_from(["batty", "inbox", "purge", "--all-roles", "--all"]);
1122        match cli.command {
1123            Command::Inbox {
1124                command:
1125                    Some(InboxCommand::Purge {
1126                        role,
1127                        all_roles,
1128                        before,
1129                        older_than,
1130                        all,
1131                    }),
1132                member,
1133                ..
1134            } => {
1135                assert!(member.is_none());
1136                assert!(role.is_none());
1137                assert!(all_roles);
1138                assert_eq!(before, None);
1139                assert!(older_than.is_none());
1140                assert!(all);
1141            }
1142            other => panic!("expected inbox purge command, got {other:?}"),
1143        }
1144    }
1145
1146    #[test]
1147    fn inbox_purge_subcommand_parses_older_than() {
1148        let cli = Cli::parse_from(["batty", "inbox", "purge", "eng-1", "--older-than", "24h"]);
1149        match cli.command {
1150            Command::Inbox {
1151                command:
1152                    Some(InboxCommand::Purge {
1153                        role,
1154                        all_roles,
1155                        before,
1156                        older_than,
1157                        all,
1158                    }),
1159                ..
1160            } => {
1161                assert_eq!(role.as_deref(), Some("eng-1"));
1162                assert!(!all_roles);
1163                assert_eq!(before, None);
1164                assert_eq!(older_than.as_deref(), Some("24h"));
1165                assert!(!all);
1166            }
1167            other => panic!("expected inbox purge command, got {other:?}"),
1168        }
1169    }
1170
1171    #[test]
1172    fn inbox_purge_rejects_older_than_with_before() {
1173        let result = Cli::try_parse_from([
1174            "batty",
1175            "inbox",
1176            "purge",
1177            "eng-1",
1178            "--older-than",
1179            "24h",
1180            "--before",
1181            "100",
1182        ]);
1183        assert!(result.is_err());
1184    }
1185
1186    #[test]
1187    fn inbox_purge_rejects_older_than_with_all() {
1188        let result = Cli::try_parse_from([
1189            "batty",
1190            "inbox",
1191            "purge",
1192            "eng-1",
1193            "--older-than",
1194            "24h",
1195            "--all",
1196        ]);
1197        assert!(result.is_err());
1198    }
1199
1200    #[test]
1201    fn read_subcommand_parses_member_and_id() {
1202        let cli = Cli::parse_from(["batty", "read", "architect", "abc123"]);
1203        match cli.command {
1204            Command::Read { member, id } => {
1205                assert_eq!(member, "architect");
1206                assert_eq!(id, "abc123");
1207            }
1208            other => panic!("expected read command, got {other:?}"),
1209        }
1210    }
1211
1212    #[test]
1213    fn ack_subcommand_parses_member_and_id() {
1214        let cli = Cli::parse_from(["batty", "ack", "eng-1-1", "abc123"]);
1215        match cli.command {
1216            Command::Ack { member, id } => {
1217                assert_eq!(member, "eng-1-1");
1218                assert_eq!(id, "abc123");
1219            }
1220            other => panic!("expected ack command, got {other:?}"),
1221        }
1222    }
1223
1224    #[test]
1225    fn pause_subcommand_parses() {
1226        let cli = Cli::parse_from(["batty", "pause"]);
1227        assert!(matches!(cli.command, Command::Pause));
1228    }
1229
1230    #[test]
1231    fn resume_subcommand_parses() {
1232        let cli = Cli::parse_from(["batty", "resume"]);
1233        assert!(matches!(cli.command, Command::Resume));
1234    }
1235
1236    #[test]
1237    fn telegram_subcommand_parses() {
1238        let cli = Cli::parse_from(["batty", "telegram"]);
1239        assert!(matches!(cli.command, Command::Telegram));
1240    }
1241
1242    #[test]
1243    fn doctor_subcommand_parses() {
1244        let cli = Cli::parse_from(["batty", "doctor"]);
1245        assert!(matches!(
1246            cli.command,
1247            Command::Doctor {
1248                fix: false,
1249                yes: false
1250            }
1251        ));
1252    }
1253
1254    #[test]
1255    fn doctor_subcommand_parses_fix_flag() {
1256        let cli = Cli::parse_from(["batty", "doctor", "--fix"]);
1257        assert!(matches!(
1258            cli.command,
1259            Command::Doctor {
1260                fix: true,
1261                yes: false
1262            }
1263        ));
1264    }
1265
1266    #[test]
1267    fn doctor_subcommand_parses_fix_yes_flags() {
1268        let cli = Cli::parse_from(["batty", "doctor", "--fix", "--yes"]);
1269        assert!(matches!(
1270            cli.command,
1271            Command::Doctor {
1272                fix: true,
1273                yes: true
1274            }
1275        ));
1276    }
1277
1278    #[test]
1279    fn load_subcommand_parses() {
1280        let cli = Cli::parse_from(["batty", "load"]);
1281        assert!(matches!(cli.command, Command::Load));
1282    }
1283
1284    #[test]
1285    fn parity_subcommand_parses_defaults() {
1286        let cli = Cli::parse_from(["batty", "parity"]);
1287        match cli.command {
1288            Command::Parity { detail, gaps } => {
1289                assert!(!detail);
1290                assert!(!gaps);
1291            }
1292            other => panic!("expected parity command, got {other:?}"),
1293        }
1294    }
1295
1296    #[test]
1297    fn parity_subcommand_parses_gaps_flag() {
1298        let cli = Cli::parse_from(["batty", "parity", "--gaps"]);
1299        match cli.command {
1300            Command::Parity { detail, gaps } => {
1301                assert!(!detail);
1302                assert!(gaps);
1303            }
1304            other => panic!("expected parity command, got {other:?}"),
1305        }
1306    }
1307
1308    #[test]
1309    fn queue_subcommand_parses() {
1310        let cli = Cli::parse_from(["batty", "queue"]);
1311        assert!(matches!(cli.command, Command::Queue));
1312    }
1313
1314    #[test]
1315    fn cost_subcommand_parses() {
1316        let cli = Cli::parse_from(["batty", "cost"]);
1317        assert!(matches!(cli.command, Command::Cost));
1318    }
1319
1320    #[test]
1321    fn verbose_flag_is_global() {
1322        let cli = Cli::parse_from(["batty", "-vv", "status"]);
1323        assert_eq!(cli.verbose, 2);
1324    }
1325
1326    #[test]
1327    fn task_transition_subcommand_parses() {
1328        let cli = Cli::parse_from(["batty", "task", "transition", "24", "in-progress"]);
1329        match cli.command {
1330            Command::Task {
1331                command:
1332                    TaskCommand::Transition {
1333                        task_id,
1334                        target_state,
1335                    },
1336            } => {
1337                assert_eq!(task_id, 24);
1338                assert_eq!(target_state, TaskStateArg::InProgress);
1339            }
1340            other => panic!("expected task transition command, got {other:?}"),
1341        }
1342    }
1343
1344    #[test]
1345    fn task_assign_subcommand_parses() {
1346        let cli = Cli::parse_from([
1347            "batty",
1348            "task",
1349            "assign",
1350            "24",
1351            "--execution-owner",
1352            "eng-1-2",
1353            "--review-owner",
1354            "manager-1",
1355        ]);
1356        match cli.command {
1357            Command::Task {
1358                command:
1359                    TaskCommand::Assign {
1360                        task_id,
1361                        execution_owner,
1362                        review_owner,
1363                    },
1364            } => {
1365                assert_eq!(task_id, 24);
1366                assert_eq!(execution_owner.as_deref(), Some("eng-1-2"));
1367                assert_eq!(review_owner.as_deref(), Some("manager-1"));
1368            }
1369            other => panic!("expected task assign command, got {other:?}"),
1370        }
1371    }
1372
1373    #[test]
1374    fn task_review_subcommand_parses() {
1375        let cli = Cli::parse_from([
1376            "batty",
1377            "task",
1378            "review",
1379            "24",
1380            "--disposition",
1381            "changes_requested",
1382        ]);
1383        match cli.command {
1384            Command::Task {
1385                command:
1386                    TaskCommand::Review {
1387                        task_id,
1388                        disposition,
1389                        feedback,
1390                    },
1391            } => {
1392                assert_eq!(task_id, 24);
1393                assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
1394                assert!(feedback.is_none());
1395            }
1396            other => panic!("expected task review command, got {other:?}"),
1397        }
1398    }
1399
1400    #[test]
1401    fn task_update_subcommand_parses() {
1402        let cli = Cli::parse_from([
1403            "batty",
1404            "task",
1405            "update",
1406            "24",
1407            "--branch",
1408            "eng-1-2/task-24",
1409            "--commit",
1410            "abc1234",
1411            "--blocked-on",
1412            "waiting for review",
1413            "--clear-blocked",
1414        ]);
1415        match cli.command {
1416            Command::Task {
1417                command:
1418                    TaskCommand::Update {
1419                        task_id,
1420                        branch,
1421                        commit,
1422                        blocked_on,
1423                        clear_blocked,
1424                    },
1425            } => {
1426                assert_eq!(task_id, 24);
1427                assert_eq!(branch.as_deref(), Some("eng-1-2/task-24"));
1428                assert_eq!(commit.as_deref(), Some("abc1234"));
1429                assert_eq!(blocked_on.as_deref(), Some("waiting for review"));
1430                assert!(clear_blocked);
1431            }
1432            other => panic!("expected task update command, got {other:?}"),
1433        }
1434    }
1435
1436    #[test]
1437    fn nudge_disable_parses() {
1438        let cli = Cli::parse_from(["batty", "nudge", "disable", "triage"]);
1439        match cli.command {
1440            Command::Nudge {
1441                command: NudgeCommand::Disable { name },
1442            } => assert_eq!(name, NudgeIntervention::Triage),
1443            other => panic!("expected nudge disable, got {other:?}"),
1444        }
1445    }
1446
1447    #[test]
1448    fn nudge_enable_parses() {
1449        let cli = Cli::parse_from(["batty", "nudge", "enable", "replenish"]);
1450        match cli.command {
1451            Command::Nudge {
1452                command: NudgeCommand::Enable { name },
1453            } => assert_eq!(name, NudgeIntervention::Replenish),
1454            other => panic!("expected nudge enable, got {other:?}"),
1455        }
1456    }
1457
1458    #[test]
1459    fn nudge_status_parses() {
1460        let cli = Cli::parse_from(["batty", "nudge", "status"]);
1461        match cli.command {
1462            Command::Nudge {
1463                command: NudgeCommand::Status,
1464            } => {}
1465            other => panic!("expected nudge status, got {other:?}"),
1466        }
1467    }
1468
1469    #[test]
1470    fn nudge_disable_owned_task_parses() {
1471        let cli = Cli::parse_from(["batty", "nudge", "disable", "owned-task"]);
1472        match cli.command {
1473            Command::Nudge {
1474                command: NudgeCommand::Disable { name },
1475            } => assert_eq!(name, NudgeIntervention::OwnedTask),
1476            other => panic!("expected nudge disable owned-task, got {other:?}"),
1477        }
1478    }
1479
1480    #[test]
1481    fn nudge_rejects_unknown_intervention() {
1482        let result = Cli::try_parse_from(["batty", "nudge", "disable", "unknown"]);
1483        assert!(result.is_err());
1484    }
1485
1486    #[test]
1487    fn nudge_intervention_marker_names() {
1488        assert_eq!(NudgeIntervention::Replenish.marker_name(), "replenish");
1489        assert_eq!(NudgeIntervention::Triage.marker_name(), "triage");
1490        assert_eq!(NudgeIntervention::Review.marker_name(), "review");
1491        assert_eq!(NudgeIntervention::Dispatch.marker_name(), "dispatch");
1492        assert_eq!(NudgeIntervention::Utilization.marker_name(), "utilization");
1493        assert_eq!(NudgeIntervention::OwnedTask.marker_name(), "owned-task");
1494    }
1495
1496    #[test]
1497    fn parse_task_schedule_at() {
1498        let cli = Cli::parse_from([
1499            "batty",
1500            "task",
1501            "schedule",
1502            "50",
1503            "--at",
1504            "2026-03-25T09:00:00-04:00",
1505        ]);
1506        match cli.command {
1507            Command::Task {
1508                command:
1509                    TaskCommand::Schedule {
1510                        task_id,
1511                        at,
1512                        cron,
1513                        clear,
1514                    },
1515            } => {
1516                assert_eq!(task_id, 50);
1517                assert_eq!(at.as_deref(), Some("2026-03-25T09:00:00-04:00"));
1518                assert!(cron.is_none());
1519                assert!(!clear);
1520            }
1521            other => panic!("expected task schedule command, got {other:?}"),
1522        }
1523    }
1524
1525    #[test]
1526    fn parse_task_schedule_cron() {
1527        let cli = Cli::parse_from(["batty", "task", "schedule", "51", "--cron", "0 9 * * *"]);
1528        match cli.command {
1529            Command::Task {
1530                command:
1531                    TaskCommand::Schedule {
1532                        task_id,
1533                        at,
1534                        cron,
1535                        clear,
1536                    },
1537            } => {
1538                assert_eq!(task_id, 51);
1539                assert!(at.is_none());
1540                assert_eq!(cron.as_deref(), Some("0 9 * * *"));
1541                assert!(!clear);
1542            }
1543            other => panic!("expected task schedule command, got {other:?}"),
1544        }
1545    }
1546
1547    #[test]
1548    fn parse_task_schedule_clear() {
1549        let cli = Cli::parse_from(["batty", "task", "schedule", "52", "--clear"]);
1550        match cli.command {
1551            Command::Task {
1552                command:
1553                    TaskCommand::Schedule {
1554                        task_id,
1555                        at,
1556                        cron,
1557                        clear,
1558                    },
1559            } => {
1560                assert_eq!(task_id, 52);
1561                assert!(at.is_none());
1562                assert!(cron.is_none());
1563                assert!(clear);
1564            }
1565            other => panic!("expected task schedule command, got {other:?}"),
1566        }
1567    }
1568
1569    #[test]
1570    fn parse_task_schedule_both() {
1571        let cli = Cli::parse_from([
1572            "batty",
1573            "task",
1574            "schedule",
1575            "53",
1576            "--at",
1577            "2026-04-01T00:00:00Z",
1578            "--cron",
1579            "0 9 * * 1",
1580        ]);
1581        match cli.command {
1582            Command::Task {
1583                command:
1584                    TaskCommand::Schedule {
1585                        task_id,
1586                        at,
1587                        cron,
1588                        clear,
1589                    },
1590            } => {
1591                assert_eq!(task_id, 53);
1592                assert_eq!(at.as_deref(), Some("2026-04-01T00:00:00Z"));
1593                assert_eq!(cron.as_deref(), Some("0 9 * * 1"));
1594                assert!(!clear);
1595            }
1596            other => panic!("expected task schedule command, got {other:?}"),
1597        }
1598    }
1599
1600    #[test]
1601    fn review_approve_parses() {
1602        let cli = Cli::parse_from(["batty", "review", "42", "approve"]);
1603        match cli.command {
1604            Command::Review {
1605                task_id,
1606                disposition,
1607                feedback,
1608                reviewer,
1609            } => {
1610                assert_eq!(task_id, 42);
1611                assert_eq!(disposition, ReviewAction::Approve);
1612                assert!(feedback.is_none());
1613                assert_eq!(reviewer, "human");
1614            }
1615            other => panic!("expected review command, got {other:?}"),
1616        }
1617    }
1618
1619    #[test]
1620    fn review_request_changes_with_feedback_parses() {
1621        let cli = Cli::parse_from([
1622            "batty",
1623            "review",
1624            "99",
1625            "request-changes",
1626            "fix the error handling",
1627        ]);
1628        match cli.command {
1629            Command::Review {
1630                task_id,
1631                disposition,
1632                feedback,
1633                reviewer,
1634            } => {
1635                assert_eq!(task_id, 99);
1636                assert_eq!(disposition, ReviewAction::RequestChanges);
1637                assert_eq!(feedback.as_deref(), Some("fix the error handling"));
1638                assert_eq!(reviewer, "human");
1639            }
1640            other => panic!("expected review command, got {other:?}"),
1641        }
1642    }
1643
1644    #[test]
1645    fn review_reject_with_reviewer_flag_parses() {
1646        let cli = Cli::parse_from([
1647            "batty",
1648            "review",
1649            "7",
1650            "reject",
1651            "does not meet requirements",
1652            "--reviewer",
1653            "manager-1",
1654        ]);
1655        match cli.command {
1656            Command::Review {
1657                task_id,
1658                disposition,
1659                feedback,
1660                reviewer,
1661            } => {
1662                assert_eq!(task_id, 7);
1663                assert_eq!(disposition, ReviewAction::Reject);
1664                assert_eq!(feedback.as_deref(), Some("does not meet requirements"));
1665                assert_eq!(reviewer, "manager-1");
1666            }
1667            other => panic!("expected review command, got {other:?}"),
1668        }
1669    }
1670
1671    #[test]
1672    fn review_rejects_invalid_disposition() {
1673        let result = Cli::try_parse_from(["batty", "review", "42", "maybe"]);
1674        assert!(result.is_err());
1675    }
1676
1677    // --- send: missing required args ---
1678
1679    #[test]
1680    fn send_rejects_missing_role() {
1681        let result = Cli::try_parse_from(["batty", "send"]);
1682        assert!(result.is_err());
1683    }
1684
1685    #[test]
1686    fn send_rejects_missing_message() {
1687        let result = Cli::try_parse_from(["batty", "send", "architect"]);
1688        assert!(result.is_err());
1689    }
1690
1691    // --- assign: missing required args ---
1692
1693    #[test]
1694    fn assign_rejects_missing_engineer() {
1695        let result = Cli::try_parse_from(["batty", "assign"]);
1696        assert!(result.is_err());
1697    }
1698
1699    #[test]
1700    fn assign_rejects_missing_task() {
1701        let result = Cli::try_parse_from(["batty", "assign", "eng-1-1"]);
1702        assert!(result.is_err());
1703    }
1704
1705    // --- review: missing required args ---
1706
1707    #[test]
1708    fn review_rejects_missing_task_id() {
1709        let result = Cli::try_parse_from(["batty", "review"]);
1710        assert!(result.is_err());
1711    }
1712
1713    #[test]
1714    fn review_rejects_missing_disposition() {
1715        let result = Cli::try_parse_from(["batty", "review", "42"]);
1716        assert!(result.is_err());
1717    }
1718
1719    // --- merge: missing required args ---
1720
1721    #[test]
1722    fn merge_rejects_missing_engineer() {
1723        let result = Cli::try_parse_from(["batty", "merge"]);
1724        assert!(result.is_err());
1725    }
1726
1727    // --- read/ack: missing required args ---
1728
1729    #[test]
1730    fn read_rejects_missing_member() {
1731        let result = Cli::try_parse_from(["batty", "read"]);
1732        assert!(result.is_err());
1733    }
1734
1735    #[test]
1736    fn read_rejects_missing_id() {
1737        let result = Cli::try_parse_from(["batty", "read", "architect"]);
1738        assert!(result.is_err());
1739    }
1740
1741    #[test]
1742    fn ack_rejects_missing_args() {
1743        let result = Cli::try_parse_from(["batty", "ack"]);
1744        assert!(result.is_err());
1745    }
1746
1747    // --- telemetry subcommands ---
1748
1749    #[test]
1750    fn telemetry_summary_parses() {
1751        let cli = Cli::parse_from(["batty", "telemetry", "summary"]);
1752        match cli.command {
1753            Command::Telemetry {
1754                command: TelemetryCommand::Summary,
1755            } => {}
1756            other => panic!("expected telemetry summary, got {other:?}"),
1757        }
1758    }
1759
1760    #[test]
1761    fn telemetry_agents_parses() {
1762        let cli = Cli::parse_from(["batty", "telemetry", "agents"]);
1763        match cli.command {
1764            Command::Telemetry {
1765                command: TelemetryCommand::Agents,
1766            } => {}
1767            other => panic!("expected telemetry agents, got {other:?}"),
1768        }
1769    }
1770
1771    #[test]
1772    fn telemetry_tasks_parses() {
1773        let cli = Cli::parse_from(["batty", "telemetry", "tasks"]);
1774        match cli.command {
1775            Command::Telemetry {
1776                command: TelemetryCommand::Tasks,
1777            } => {}
1778            other => panic!("expected telemetry tasks, got {other:?}"),
1779        }
1780    }
1781
1782    #[test]
1783    fn telemetry_reviews_parses() {
1784        let cli = Cli::parse_from(["batty", "telemetry", "reviews"]);
1785        match cli.command {
1786            Command::Telemetry {
1787                command: TelemetryCommand::Reviews,
1788            } => {}
1789            other => panic!("expected telemetry reviews, got {other:?}"),
1790        }
1791    }
1792
1793    #[test]
1794    fn telemetry_events_default_limit() {
1795        let cli = Cli::parse_from(["batty", "telemetry", "events"]);
1796        match cli.command {
1797            Command::Telemetry {
1798                command: TelemetryCommand::Events { limit },
1799            } => assert_eq!(limit, 50),
1800            other => panic!("expected telemetry events, got {other:?}"),
1801        }
1802    }
1803
1804    #[test]
1805    fn telemetry_events_custom_limit() {
1806        let cli = Cli::parse_from(["batty", "telemetry", "events", "-n", "10"]);
1807        match cli.command {
1808            Command::Telemetry {
1809                command: TelemetryCommand::Events { limit },
1810            } => assert_eq!(limit, 10),
1811            other => panic!("expected telemetry events with limit, got {other:?}"),
1812        }
1813    }
1814
1815    #[test]
1816    fn telemetry_rejects_missing_subcommand() {
1817        let result = Cli::try_parse_from(["batty", "telemetry"]);
1818        assert!(result.is_err());
1819    }
1820
1821    // --- grafana ---
1822
1823    #[test]
1824    fn grafana_setup_parses() {
1825        let cli = Cli::parse_from(["batty", "grafana", "setup"]);
1826        assert!(matches!(
1827            cli.command,
1828            Command::Grafana {
1829                command: GrafanaCommand::Setup
1830            }
1831        ));
1832    }
1833
1834    #[test]
1835    fn grafana_status_parses() {
1836        let cli = Cli::parse_from(["batty", "grafana", "status"]);
1837        assert!(matches!(
1838            cli.command,
1839            Command::Grafana {
1840                command: GrafanaCommand::Status
1841            }
1842        ));
1843    }
1844
1845    #[test]
1846    fn grafana_open_parses() {
1847        let cli = Cli::parse_from(["batty", "grafana", "open"]);
1848        assert!(matches!(
1849            cli.command,
1850            Command::Grafana {
1851                command: GrafanaCommand::Open
1852            }
1853        ));
1854    }
1855
1856    #[test]
1857    fn grafana_rejects_missing_subcommand() {
1858        let result = Cli::try_parse_from(["batty", "grafana"]);
1859        assert!(result.is_err());
1860    }
1861
1862    // --- task auto-merge ---
1863
1864    #[test]
1865    fn task_auto_merge_enable_parses() {
1866        let cli = Cli::parse_from(["batty", "task", "auto-merge", "30", "enable"]);
1867        match cli.command {
1868            Command::Task {
1869                command: TaskCommand::AutoMerge { task_id, action },
1870            } => {
1871                assert_eq!(task_id, 30);
1872                assert_eq!(action, AutoMergeAction::Enable);
1873            }
1874            other => panic!("expected task auto-merge enable, got {other:?}"),
1875        }
1876    }
1877
1878    #[test]
1879    fn task_auto_merge_disable_parses() {
1880        let cli = Cli::parse_from(["batty", "task", "auto-merge", "31", "disable"]);
1881        match cli.command {
1882            Command::Task {
1883                command: TaskCommand::AutoMerge { task_id, action },
1884            } => {
1885                assert_eq!(task_id, 31);
1886                assert_eq!(action, AutoMergeAction::Disable);
1887            }
1888            other => panic!("expected task auto-merge disable, got {other:?}"),
1889        }
1890    }
1891
1892    #[test]
1893    fn task_auto_merge_rejects_invalid_action() {
1894        let result = Cli::try_parse_from(["batty", "task", "auto-merge", "30", "toggle"]);
1895        assert!(result.is_err());
1896    }
1897
1898    // --- task assign with partial owners ---
1899
1900    #[test]
1901    fn task_assign_execution_owner_only() {
1902        let cli = Cli::parse_from([
1903            "batty",
1904            "task",
1905            "assign",
1906            "10",
1907            "--execution-owner",
1908            "eng-1-3",
1909        ]);
1910        match cli.command {
1911            Command::Task {
1912                command:
1913                    TaskCommand::Assign {
1914                        task_id,
1915                        execution_owner,
1916                        review_owner,
1917                    },
1918            } => {
1919                assert_eq!(task_id, 10);
1920                assert_eq!(execution_owner.as_deref(), Some("eng-1-3"));
1921                assert!(review_owner.is_none());
1922            }
1923            other => panic!("expected task assign command, got {other:?}"),
1924        }
1925    }
1926
1927    // --- task rejects missing subcommand ---
1928
1929    #[test]
1930    fn task_rejects_missing_subcommand() {
1931        let result = Cli::try_parse_from(["batty", "task"]);
1932        assert!(result.is_err());
1933    }
1934
1935    // --- doctor: --yes requires --fix ---
1936
1937    #[test]
1938    fn doctor_rejects_yes_without_fix() {
1939        let result = Cli::try_parse_from(["batty", "doctor", "--yes"]);
1940        assert!(result.is_err());
1941    }
1942
1943    // --- daemon hidden subcommand ---
1944
1945    #[test]
1946    fn daemon_subcommand_parses() {
1947        let cli = Cli::parse_from(["batty", "daemon", "--project-root", "/tmp/project"]);
1948        match cli.command {
1949            Command::Daemon {
1950                project_root,
1951                resume,
1952            } => {
1953                assert_eq!(project_root, "/tmp/project");
1954                assert!(!resume);
1955            }
1956            other => panic!("expected daemon command, got {other:?}"),
1957        }
1958    }
1959
1960    #[test]
1961    fn daemon_subcommand_parses_resume_flag() {
1962        let cli = Cli::parse_from([
1963            "batty",
1964            "daemon",
1965            "--project-root",
1966            "/tmp/project",
1967            "--resume",
1968        ]);
1969        match cli.command {
1970            Command::Daemon {
1971                project_root,
1972                resume,
1973            } => {
1974                assert_eq!(project_root, "/tmp/project");
1975                assert!(resume);
1976            }
1977            other => panic!("expected daemon command with resume, got {other:?}"),
1978        }
1979    }
1980
1981    // --- completions: all shell variants ---
1982
1983    #[test]
1984    fn completions_all_shells_parse() {
1985        for (arg, expected) in [
1986            ("bash", CompletionShell::Bash),
1987            ("zsh", CompletionShell::Zsh),
1988            ("fish", CompletionShell::Fish),
1989        ] {
1990            let cli = Cli::parse_from(["batty", "completions", arg]);
1991            match cli.command {
1992                Command::Completions { shell } => assert_eq!(shell, expected, "shell arg={arg}"),
1993                other => panic!("expected completions command for {arg}, got {other:?}"),
1994            }
1995        }
1996    }
1997
1998    #[test]
1999    fn completions_rejects_unknown_shell() {
2000        let result = Cli::try_parse_from(["batty", "completions", "powershell"]);
2001        assert!(result.is_err());
2002    }
2003
2004    // --- init: all template variants ---
2005
2006    #[test]
2007    fn init_all_template_variants() {
2008        for (arg, expected) in [
2009            ("solo", InitTemplate::Solo),
2010            ("pair", InitTemplate::Pair),
2011            ("simple", InitTemplate::Simple),
2012            ("squad", InitTemplate::Squad),
2013            ("large", InitTemplate::Large),
2014            ("research", InitTemplate::Research),
2015            ("software", InitTemplate::Software),
2016            ("cleanroom", InitTemplate::Cleanroom),
2017            ("batty", InitTemplate::Batty),
2018        ] {
2019            let cli = Cli::parse_from(["batty", "init", "--template", arg]);
2020            match cli.command {
2021                Command::Init { template, from, .. } => {
2022                    assert_eq!(template, Some(expected), "template arg={arg}");
2023                    assert!(from.is_none());
2024                }
2025                other => panic!("expected init command for template {arg}, got {other:?}"),
2026            }
2027        }
2028    }
2029
2030    // --- task review with feedback ---
2031
2032    #[test]
2033    fn task_review_with_feedback_parses() {
2034        let cli = Cli::parse_from([
2035            "batty",
2036            "task",
2037            "review",
2038            "15",
2039            "--disposition",
2040            "changes_requested",
2041            "--feedback",
2042            "please fix tests",
2043        ]);
2044        match cli.command {
2045            Command::Task {
2046                command:
2047                    TaskCommand::Review {
2048                        task_id,
2049                        disposition,
2050                        feedback,
2051                    },
2052            } => {
2053                assert_eq!(task_id, 15);
2054                assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
2055                assert_eq!(feedback.as_deref(), Some("please fix tests"));
2056            }
2057            other => panic!("expected task review command, got {other:?}"),
2058        }
2059    }
2060
2061    // --- task transition: all states ---
2062
2063    #[test]
2064    fn task_transition_all_states() {
2065        for (arg, expected) in [
2066            ("backlog", TaskStateArg::Backlog),
2067            ("todo", TaskStateArg::Todo),
2068            ("in-progress", TaskStateArg::InProgress),
2069            ("review", TaskStateArg::Review),
2070            ("blocked", TaskStateArg::Blocked),
2071            ("done", TaskStateArg::Done),
2072            ("archived", TaskStateArg::Archived),
2073        ] {
2074            let cli = Cli::parse_from(["batty", "task", "transition", "1", arg]);
2075            match cli.command {
2076                Command::Task {
2077                    command:
2078                        TaskCommand::Transition {
2079                            task_id,
2080                            target_state,
2081                        },
2082                } => {
2083                    assert_eq!(task_id, 1);
2084                    assert_eq!(target_state, expected, "state arg={arg}");
2085                }
2086                other => panic!("expected task transition for {arg}, got {other:?}"),
2087            }
2088        }
2089    }
2090
2091    #[test]
2092    fn task_transition_rejects_invalid_state() {
2093        let result = Cli::try_parse_from(["batty", "task", "transition", "1", "cancelled"]);
2094        assert!(result.is_err());
2095    }
2096
2097    // --- unknown subcommand ---
2098
2099    #[test]
2100    fn rejects_unknown_subcommand() {
2101        let result = Cli::try_parse_from(["batty", "foobar"]);
2102        assert!(result.is_err());
2103    }
2104
2105    // --- no args ---
2106
2107    #[test]
2108    fn rejects_no_subcommand() {
2109        let result = Cli::try_parse_from(["batty"]);
2110        assert!(result.is_err());
2111    }
2112
2113    // --- inbox purge requires role or all-roles ---
2114
2115    #[test]
2116    fn inbox_purge_rejects_missing_role_and_all_roles() {
2117        let result = Cli::try_parse_from(["batty", "inbox", "purge", "--all"]);
2118        assert!(result.is_err());
2119    }
2120
2121    // --- nudge: all intervention variants ---
2122
2123    #[test]
2124    fn nudge_enable_all_interventions() {
2125        for (arg, expected) in [
2126            ("replenish", NudgeIntervention::Replenish),
2127            ("triage", NudgeIntervention::Triage),
2128            ("review", NudgeIntervention::Review),
2129            ("dispatch", NudgeIntervention::Dispatch),
2130            ("utilization", NudgeIntervention::Utilization),
2131            ("owned-task", NudgeIntervention::OwnedTask),
2132        ] {
2133            let cli = Cli::parse_from(["batty", "nudge", "enable", arg]);
2134            match cli.command {
2135                Command::Nudge {
2136                    command: NudgeCommand::Enable { name },
2137                } => assert_eq!(name, expected, "nudge enable arg={arg}"),
2138                other => panic!("expected nudge enable for {arg}, got {other:?}"),
2139            }
2140        }
2141    }
2142
2143    // --- config: default (no --json) ---
2144
2145    #[test]
2146    fn config_subcommand_defaults_no_json() {
2147        let cli = Cli::parse_from(["batty", "config"]);
2148        match cli.command {
2149            Command::Config { json } => assert!(!json),
2150            other => panic!("expected config command, got {other:?}"),
2151        }
2152    }
2153
2154    // --- completion generation tests ---
2155
2156    /// Helper: generate completion script for a shell into a String.
2157    fn generate_completions(shell: clap_complete::Shell) -> String {
2158        use clap::CommandFactory;
2159        let mut buf = Vec::new();
2160        clap_complete::generate(shell, &mut Cli::command(), "batty", &mut buf);
2161        String::from_utf8(buf).expect("completions should be valid UTF-8")
2162    }
2163
2164    #[test]
2165    fn completions_bash_generates() {
2166        let output = generate_completions(clap_complete::Shell::Bash);
2167        assert!(!output.is_empty(), "bash completions should not be empty");
2168        assert!(
2169            output.contains("_batty"),
2170            "bash completions should define _batty function"
2171        );
2172    }
2173
2174    #[test]
2175    fn completions_zsh_generates() {
2176        let output = generate_completions(clap_complete::Shell::Zsh);
2177        assert!(!output.is_empty(), "zsh completions should not be empty");
2178        assert!(
2179            output.contains("#compdef batty"),
2180            "zsh completions should start with #compdef"
2181        );
2182    }
2183
2184    #[test]
2185    fn completions_fish_generates() {
2186        let output = generate_completions(clap_complete::Shell::Fish);
2187        assert!(!output.is_empty(), "fish completions should not be empty");
2188        assert!(
2189            output.contains("complete -c batty"),
2190            "fish completions should contain complete -c batty"
2191        );
2192    }
2193
2194    #[test]
2195    fn completions_include_grafana_subcommands() {
2196        let output = generate_completions(clap_complete::Shell::Fish);
2197        // Top-level grafana command
2198        assert!(
2199            output.contains("grafana"),
2200            "completions should include grafana command"
2201        );
2202        // Grafana subcommands
2203        assert!(
2204            output.contains("setup"),
2205            "completions should include grafana setup"
2206        );
2207        assert!(
2208            output.contains("status"),
2209            "completions should include grafana status"
2210        );
2211        assert!(
2212            output.contains("open"),
2213            "completions should include grafana open"
2214        );
2215    }
2216
2217    #[test]
2218    fn completions_include_all_recent_commands() {
2219        let output = generate_completions(clap_complete::Shell::Fish);
2220        let expected_commands = [
2221            "task",
2222            "metrics",
2223            "grafana",
2224            "telemetry",
2225            "nudge",
2226            "load",
2227            "queue",
2228            "cost",
2229            "doctor",
2230            "pause",
2231            "resume",
2232        ];
2233        for cmd in &expected_commands {
2234            assert!(
2235                output.contains(cmd),
2236                "completions should include '{cmd}' command"
2237            );
2238        }
2239    }
2240}