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