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