Skip to main content

batty_cli/
cli.rs

1use std::path::PathBuf;
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5#[derive(Parser, Debug)]
6#[command(
7    name = "batty",
8    about = "Hierarchical agent team system for software development",
9    version = concat!(env!("CARGO_PKG_VERSION"), "\nhttps://github.com/battysh/batty")
10)]
11pub struct Cli {
12    #[command(subcommand)]
13    pub command: Command,
14
15    /// Verbosity level (-v, -vv, -vvv)
16    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
17    pub verbose: u8,
18}
19
20#[derive(Subcommand, Debug)]
21pub enum Command {
22    /// Scaffold .batty/team_config/ with default team.yaml and prompt templates
23    #[command(alias = "install")]
24    Init {
25        /// Template to use for scaffolding
26        #[arg(long, value_enum, conflicts_with = "from")]
27        template: Option<InitTemplate>,
28        /// Copy team config from $HOME/.batty/templates/<name>/
29        #[arg(long, conflicts_with = "template")]
30        from: Option<String>,
31        /// Overwrite existing team config files
32        #[arg(long)]
33        force: bool,
34        /// Default agent backend for all roles (claude, codex, kiro)
35        #[arg(long)]
36        agent: Option<String>,
37    },
38
39    /// Export the current team config as a reusable template
40    ExportTemplate {
41        /// Template name
42        name: String,
43    },
44
45    /// Export run state for debugging
46    ExportRun,
47
48    /// Generate a run retrospective
49    Retro {
50        /// Path to events.jsonl (default: .batty/team_config/events.jsonl)
51        #[arg(long)]
52        events: Option<PathBuf>,
53    },
54
55    /// Start the team daemon and tmux session
56    Start {
57        /// Auto-attach to the tmux session after startup
58        #[arg(long, default_value_t = false)]
59        attach: bool,
60    },
61
62    /// Stop the team daemon and kill the tmux session
63    Stop,
64
65    /// Attach to the running team tmux session
66    Attach,
67
68    /// Show all team members and their states
69    Status {
70        /// Emit machine-readable JSON output
71        #[arg(long, default_value_t = false)]
72        json: bool,
73        /// Include detailed telemetry-backed engineer profiles
74        #[arg(long, default_value_t = false)]
75        detail: bool,
76        /// Show optional subsystem health and error-budget state
77        #[arg(long, default_value_t = false)]
78        health: bool,
79    },
80
81    /// Prevent an engineer from receiving new auto-dispatch work
82    Bench {
83        /// Engineer instance (e.g., "eng-1-1")
84        engineer: String,
85        /// Optional reason recorded in bench.yaml and status output
86        #[arg(long)]
87        reason: Option<String>,
88    },
89
90    /// Remove an engineer from the durable bench list
91    Unbench {
92        /// Engineer instance (e.g., "eng-1-1")
93        engineer: String,
94    },
95
96    /// OpenClaw supervisor integration helpers for Batty
97    #[command(name = "openclaw")]
98    OpenClaw {
99        #[command(subcommand)]
100        command: OpenClawCommand,
101    },
102
103    /// Send a message to an agent role (human → agent injection)
104    Send {
105        /// Explicit sender override (hidden; used by pane bridge and automation)
106        #[arg(long, hide = true)]
107        from: Option<String>,
108        /// Target role name (e.g., "architect", "manager-1")
109        role: String,
110        /// Message to inject
111        message: String,
112    },
113
114    /// Assign a task to an engineer (used by manager agent)
115    Assign {
116        /// Target engineer instance (e.g., "eng-1-1")
117        engineer: String,
118        /// Task description
119        task: String,
120    },
121
122    /// Validate team config without launching
123    Validate {
124        /// Show all individual checks with pass/fail status
125        #[arg(long, default_value_t = false)]
126        show_checks: bool,
127    },
128
129    /// Show resolved team configuration
130    Config {
131        /// Emit machine-readable JSON output
132        #[arg(long, default_value_t = false)]
133        json: bool,
134    },
135
136    /// Show the kanban board
137    Board {
138        #[command(subcommand)]
139        command: Option<BoardCommand>,
140    },
141
142    /// List inbox messages for a team member, or purge delivered inbox messages
143    #[command(args_conflicts_with_subcommands = true)]
144    Inbox {
145        #[command(subcommand)]
146        command: Option<InboxCommand>,
147        /// Member name (e.g., "architect", "manager-1", "eng-1-1")
148        member: Option<String>,
149        /// Maximum number of recent messages to show
150        #[arg(
151            short = 'n',
152            long = "limit",
153            default_value_t = 20,
154            conflicts_with = "all"
155        )]
156        limit: usize,
157        /// Show all messages
158        #[arg(long, default_value_t = false)]
159        all: bool,
160        /// Show all raw messages without digest collapsing
161        #[arg(long, default_value_t = false)]
162        raw: bool,
163    },
164
165    /// Read a specific message from a member's inbox
166    Read {
167        /// Member name
168        member: String,
169        /// Message REF, ID, or ID prefix from `batty inbox` output
170        id: String,
171    },
172
173    /// Acknowledge (mark delivered) a message in a member's inbox
174    Ack {
175        /// Member name
176        member: String,
177        /// Message REF, ID, or ID prefix from `batty inbox` output
178        id: String,
179    },
180
181    /// Merge an engineer's worktree branch into main
182    Merge {
183        /// Engineer instance name (e.g., "eng-1-1")
184        engineer: String,
185    },
186
187    /// Manage workflow task state and metadata
188    Task {
189        #[command(subcommand)]
190        command: TaskCommand,
191    },
192
193    /// Record a structured review disposition for a task
194    Review {
195        /// Task id
196        task_id: u32,
197        /// Review disposition
198        #[arg(value_enum)]
199        disposition: ReviewAction,
200        /// Feedback text
201        feedback: Option<String>,
202        /// Reviewer name (default: human)
203        #[arg(long, default_value = "human")]
204        reviewer: String,
205    },
206
207    /// Generate shell completions
208    Completions {
209        /// Shell to generate completion script for
210        #[arg(value_enum)]
211        shell: CompletionShell,
212    },
213
214    /// Per-intervention runtime toggles
215    Nudge {
216        #[command(subcommand)]
217        command: NudgeCommand,
218    },
219
220    /// Pause nudges and standups
221    Pause,
222
223    /// Resume nudges and standups
224    Resume,
225
226    /// Manage Grafana monitoring (setup, status, open)
227    Grafana {
228        #[command(subcommand)]
229        command: GrafanaCommand,
230    },
231
232    /// Configure Discord human communication
233    Discord {
234        #[command(subcommand)]
235        command: Option<DiscordCommand>,
236    },
237
238    /// Set up Telegram bot for human communication
239    Telegram,
240
241    /// Manage the global Batty project registry for multi-project supervision
242    Project {
243        #[command(subcommand)]
244        command: ProjectCommand,
245    },
246
247    /// Estimate team load and show recent load history
248    Load,
249
250    /// Show clean-room parity summary from PARITY.md
251    Parity {
252        /// Show the full parity table
253        #[arg(long, default_value_t = false, conflicts_with = "gaps")]
254        detail: bool,
255        /// Show only behaviors with missing tests or implementation
256        #[arg(long, default_value_t = false)]
257        gaps: bool,
258    },
259
260    /// Run clean-room equivalence verification from PARITY.md
261    Verify,
262
263    /// Validate clean main, assemble release notes, and create a release tag
264    Release {
265        /// Override the git tag to create (default: v<Cargo.toml version>)
266        #[arg(long)]
267        tag: Option<String>,
268    },
269
270    /// Show pending dispatch queue entries
271    Queue,
272
273    /// Explain which engineer would receive the next dispatch
274    Dispatch {
275        /// Print routing reasons for the chosen engineer
276        #[arg(long, default_value_t = false)]
277        explain: bool,
278        /// Explain routing for a specific task id
279        #[arg(long)]
280        task: Option<u32>,
281    },
282
283    /// Estimate current run cost from agent session files
284    Cost,
285
286    /// Dynamically scale team topology (add/remove agents)
287    Scale {
288        #[command(subcommand)]
289        command: ScaleCommand,
290    },
291
292    /// Trigger an explicit topology reload for the running daemon
293    Reload,
294
295    /// Run autonomous evaluator-driven research missions
296    Research {
297        #[command(subcommand)]
298        command: ResearchCommand,
299    },
300
301    /// Dump diagnostic state from Batty state files
302    Doctor {
303        /// Remove orphan branches and worktrees after confirmation
304        #[arg(long, default_value_t = false)]
305        fix: bool,
306        /// Skip the cleanup confirmation prompt
307        #[arg(long, default_value_t = false, requires = "fix")]
308        yes: bool,
309    },
310
311    /// Inspect engineer worktree health
312    Worktree {
313        /// Show the current worktree health report
314        #[arg(long, default_value_t = false)]
315        health: bool,
316    },
317
318    /// Show consolidated telemetry dashboard (tasks, cycle time, rates, agents)
319    Metrics,
320
321    /// Run a synthetic long-session stress harness with fault injection
322    StressTest {
323        /// Run the full fault matrix on an accelerated compact timeline for CI
324        #[arg(long, default_value_t = false)]
325        compact: bool,
326
327        /// Virtual session duration in hours when not using compact mode
328        #[arg(long, default_value_t = 8)]
329        duration_hours: u64,
330
331        /// Deterministic seed for fault scheduling and synthetic recovery timings
332        #[arg(long, default_value_t = 1)]
333        seed: u64,
334
335        /// Override the JSON report output path
336        #[arg(long)]
337        json_out: Option<PathBuf>,
338
339        /// Override the Markdown report output path
340        #[arg(long)]
341        markdown_out: Option<PathBuf>,
342    },
343
344    /// Query the telemetry database for agent and task metrics
345    Telemetry {
346        #[command(subcommand)]
347        command: TelemetryCommand,
348    },
349
350    /// Interactive chat with an agent via the shim protocol
351    Chat {
352        /// Agent type: claude, codex, kiro, generic
353        #[arg(long, default_value = "generic")]
354        agent_type: String,
355
356        /// Shell command to launch the agent CLI (auto-detected from agent type if omitted)
357        #[arg(long)]
358        cmd: Option<String>,
359
360        /// Working directory for the agent
361        #[arg(long, default_value = ".")]
362        cwd: String,
363
364        /// Use SDK mode (NDJSON stdin/stdout) instead of PTY screen-scraping
365        #[arg(long, default_value_t = false)]
366        sdk_mode: bool,
367    },
368
369    /// Internal: run a shim process (spawned by `batty chat` or orchestrator)
370    #[command(hide = true)]
371    Shim {
372        /// Unique agent identifier
373        #[arg(long)]
374        id: String,
375
376        /// Agent type: claude, codex, kiro, generic
377        #[arg(long)]
378        agent_type: String,
379
380        /// Shell command to launch the agent CLI
381        #[arg(long)]
382        cmd: String,
383
384        /// Working directory for the agent
385        #[arg(long)]
386        cwd: String,
387
388        /// Terminal rows
389        #[arg(long, default_value = "50")]
390        rows: u16,
391
392        /// Terminal columns
393        #[arg(long, default_value = "220")]
394        cols: u16,
395
396        /// Path to write raw PTY output for tmux display panes
397        #[arg(long)]
398        pty_log_path: Option<String>,
399
400        /// Seconds to wait for auto-commit before force-killing.
401        #[arg(long, default_value_t = 5)]
402        graceful_shutdown_timeout_secs: u64,
403
404        /// Auto-commit dirty worktrees before restart/shutdown.
405        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
406        auto_commit_on_restart: bool,
407
408        /// Use SDK mode (NDJSON stdin/stdout) instead of PTY screen-scraping
409        #[arg(long, default_value_t = false)]
410        sdk_mode: bool,
411    },
412
413    /// Internal: interactive shim pane bridge for tmux
414    #[command(hide = true)]
415    ConsolePane {
416        /// Project root directory
417        #[arg(long)]
418        project_root: String,
419
420        /// Member/agent id
421        #[arg(long)]
422        member: String,
423
424        /// Path to the shim event log
425        #[arg(long)]
426        events_log_path: String,
427
428        /// Path to the shim PTY log
429        #[arg(long)]
430        pty_log_path: String,
431    },
432
433    /// Internal: receive Grafana webhook alerts and append them to alerts.log
434    #[command(hide = true)]
435    GrafanaWebhook {
436        /// Project root directory
437        #[arg(long)]
438        project_root: String,
439
440        /// Local port to bind for webhook delivery
441        #[arg(long, default_value_t = 8787)]
442        port: u16,
443    },
444
445    /// Internal: run the daemon loop (spawned by `batty start`)
446    #[command(hide = true)]
447    Daemon {
448        /// Project root directory
449        #[arg(long)]
450        project_root: String,
451        /// Resume agent sessions from a previous run
452        #[arg(long)]
453        resume: bool,
454    },
455
456    /// Internal: run the daemon watchdog supervisor (spawned by `batty start`)
457    #[command(hide = true)]
458    Watchdog {
459        /// Project root directory
460        #[arg(long)]
461        project_root: String,
462        /// Resume agent sessions from a previous run
463        #[arg(long)]
464        resume: bool,
465    },
466}
467
468#[derive(Subcommand, Debug)]
469pub enum TelemetryCommand {
470    /// Show session summaries
471    Summary,
472    /// Show per-agent performance metrics
473    Agents,
474    /// Show per-task lifecycle metrics
475    Tasks,
476    /// Show review pipeline metrics (auto-merge rate, rework, latency)
477    Reviews,
478    /// Show recent events from the telemetry database
479    Events {
480        /// Maximum number of events to show
481        #[arg(short = 'n', long = "limit", default_value_t = 50)]
482        limit: usize,
483    },
484}
485
486#[derive(Subcommand, Debug)]
487pub enum GrafanaCommand {
488    /// Install Grafana and the SQLite datasource plugin, then start the service
489    Setup,
490    /// Check whether the Grafana server is reachable
491    Status,
492    /// Open the Grafana dashboard in the default browser
493    Open,
494}
495
496#[derive(Subcommand, Debug)]
497pub enum DiscordCommand {
498    /// Run the interactive Discord setup wizard
499    Setup,
500    /// Show the current Discord connection health
501    Status,
502}
503
504#[derive(Subcommand, Debug)]
505pub enum ProjectCommand {
506    /// Register a project in the global Batty/OpenClaw registry
507    Register {
508        /// Stable unique project identifier
509        #[arg(long = "project-id")]
510        project_id: String,
511        /// Operator-facing project name
512        #[arg(long)]
513        name: String,
514        /// Alternate operator-facing aliases used for routing
515        #[arg(long = "alias")]
516        aliases: Vec<String>,
517        /// Repository root for the supervised project
518        #[arg(long = "project-root")]
519        project_root: PathBuf,
520        /// Kanban board directory for the project
521        #[arg(long = "board-dir")]
522        board_dir: PathBuf,
523        /// Batty team name from team.yaml
524        #[arg(long = "team-name")]
525        team_name: String,
526        /// Explicit runtime session name
527        #[arg(long = "session-name")]
528        session_name: String,
529        /// Optional owner or owning team
530        #[arg(long)]
531        owner: Option<String>,
532        /// Tag metadata
533        #[arg(long = "tag")]
534        tags: Vec<String>,
535        /// Channel binding in the form <channel>=<binding>
536        #[arg(long = "channel-binding")]
537        channel_bindings: Vec<String>,
538        /// Thread binding in the form <channel>=<binding>#<thread-binding>
539        #[arg(long = "thread-binding")]
540        thread_bindings: Vec<String>,
541        /// Allow OpenClaw supervision actions for this project
542        #[arg(long, default_value_t = false)]
543        allow_openclaw_supervision: bool,
544        /// Allow cross-project routing for this project
545        #[arg(long, default_value_t = false)]
546        allow_cross_project_routing: bool,
547        /// Allow shared-service routing for this project
548        #[arg(long, default_value_t = false)]
549        allow_shared_service_routing: bool,
550        /// Mark the project archived at registration time
551        #[arg(long, default_value_t = false)]
552        archived: bool,
553        /// Emit machine-readable JSON output
554        #[arg(long, default_value_t = false)]
555        json: bool,
556    },
557    /// Remove a project from the registry
558    Unregister {
559        /// Stable unique project identifier
560        project_id: String,
561        /// Emit machine-readable JSON output
562        #[arg(long, default_value_t = false)]
563        json: bool,
564    },
565    /// List all registered projects
566    List {
567        /// Emit machine-readable JSON output
568        #[arg(long, default_value_t = false)]
569        json: bool,
570    },
571    /// Show one registered project by projectId
572    Get {
573        /// Stable unique project identifier
574        project_id: String,
575        /// Emit machine-readable JSON output
576        #[arg(long, default_value_t = false)]
577        json: bool,
578    },
579    /// Start one registered project by projectId
580    Start {
581        /// Stable unique project identifier
582        project_id: String,
583        /// Emit machine-readable JSON output
584        #[arg(long, default_value_t = false)]
585        json: bool,
586    },
587    /// Stop one registered project by projectId
588    Stop {
589        /// Stable unique project identifier
590        project_id: String,
591        /// Emit machine-readable JSON output
592        #[arg(long, default_value_t = false)]
593        json: bool,
594    },
595    /// Restart one registered project by projectId
596    Restart {
597        /// Stable unique project identifier
598        project_id: String,
599        /// Emit machine-readable JSON output
600        #[arg(long, default_value_t = false)]
601        json: bool,
602    },
603    /// Show lifecycle and health status for one registered project
604    Status {
605        /// Stable unique project identifier
606        project_id: String,
607        /// Emit machine-readable JSON output
608        #[arg(long, default_value_t = false)]
609        json: bool,
610    },
611    /// Set the active project used for implicit routing
612    SetActive {
613        /// Stable unique project identifier
614        project_id: String,
615        /// Optional channel provider name for channel-scoped activation
616        #[arg(long)]
617        channel: Option<String>,
618        /// Optional channel binding identifier for channel-scoped activation
619        #[arg(long)]
620        binding: Option<String>,
621        /// Optional thread binding identifier for thread-scoped activation
622        #[arg(long = "thread-binding")]
623        thread_binding: Option<String>,
624        /// Emit machine-readable JSON output
625        #[arg(long, default_value_t = false)]
626        json: bool,
627    },
628    /// Resolve which project a message should route to
629    Resolve {
630        /// Message text to resolve
631        message: String,
632        /// Optional channel provider name
633        #[arg(long)]
634        channel: Option<String>,
635        /// Optional channel binding identifier
636        #[arg(long)]
637        binding: Option<String>,
638        /// Optional thread binding identifier
639        #[arg(long = "thread-binding")]
640        thread_binding: Option<String>,
641        /// Emit machine-readable JSON output
642        #[arg(long, default_value_t = false)]
643        json: bool,
644    },
645}
646
647#[derive(Subcommand, Debug)]
648pub enum InboxCommand {
649    /// Purge delivered messages from inbox cur/ directories
650    Purge {
651        /// Role/member name to purge
652        #[arg(required_unless_present = "all_roles")]
653        role: Option<String>,
654        /// Purge delivered messages for every inbox
655        #[arg(long, default_value_t = false)]
656        all_roles: bool,
657        /// Purge delivered messages older than this unix timestamp
658        #[arg(long, conflicts_with_all = ["all", "older_than"])]
659        before: Option<u64>,
660        /// Purge delivered messages older than this duration (e.g. 24h, 7d, 2w)
661        #[arg(long, conflicts_with_all = ["all", "before"])]
662        older_than: Option<String>,
663        /// Purge all delivered messages
664        #[arg(long, default_value_t = false, conflicts_with_all = ["before", "older_than"])]
665        all: bool,
666    },
667}
668
669#[derive(Subcommand, Debug)]
670pub enum BoardCommand {
671    /// List board tasks in a non-interactive table
672    List {
673        /// Filter tasks by status
674        #[arg(long)]
675        status: Option<String>,
676    },
677    /// Show per-status task counts
678    Summary,
679    /// Show dependency graph
680    Deps {
681        /// Output format: tree (default), flat, or dot
682        #[arg(long, value_enum, default_value_t = DepsFormatArg::Tree)]
683        format: DepsFormatArg,
684    },
685    /// Move done tasks to archive directory
686    Archive {
687        /// Only archive tasks older than this (e.g. "7d", "24h", "2w", or ISO date)
688        #[arg(long, default_value = "0s")]
689        older_than: String,
690
691        /// Show what would be archived without moving files
692        #[arg(long)]
693        dry_run: bool,
694    },
695    /// Show board health dashboard
696    Health,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
700pub enum DepsFormatArg {
701    Tree,
702    Flat,
703    Dot,
704}
705
706#[derive(Subcommand, Debug)]
707pub enum TaskCommand {
708    /// Transition a task to a new workflow state
709    Transition {
710        /// Task id
711        task_id: u32,
712        /// Target state
713        #[arg(value_enum)]
714        target_state: TaskStateArg,
715    },
716
717    /// Assign execution and/or review ownership
718    Assign {
719        /// Task id
720        task_id: u32,
721        /// Execution owner
722        #[arg(long = "execution-owner")]
723        execution_owner: Option<String>,
724        /// Review owner
725        #[arg(long = "review-owner")]
726        review_owner: Option<String>,
727    },
728
729    /// Record a review disposition for a task
730    Review {
731        /// Task id
732        task_id: u32,
733        /// Review disposition
734        #[arg(long, value_enum)]
735        disposition: ReviewDispositionArg,
736        /// Feedback text (stored and delivered for changes_requested)
737        #[arg(long)]
738        feedback: Option<String>,
739    },
740
741    /// Update workflow metadata fields
742    Update {
743        /// Task id
744        task_id: u32,
745        /// Worktree branch
746        #[arg(long)]
747        branch: Option<String>,
748        /// Commit sha
749        #[arg(long)]
750        commit: Option<String>,
751        /// Blocking reason
752        #[arg(long = "blocked-on")]
753        blocked_on: Option<String>,
754        /// Clear blocking fields
755        #[arg(long = "clear-blocked", default_value_t = false)]
756        clear_blocked: bool,
757    },
758
759    /// Set per-task auto-merge override
760    #[command(name = "auto-merge")]
761    AutoMerge {
762        /// Task id
763        task_id: u32,
764        /// Enable or disable auto-merge for this task
765        #[arg(value_enum)]
766        action: AutoMergeAction,
767    },
768
769    /// Set scheduled_for and/or cron_schedule on a task
770    Schedule {
771        /// Task id
772        task_id: u32,
773        /// Scheduled datetime in RFC3339 format (e.g. 2026-03-25T09:00:00-04:00)
774        #[arg(long = "at")]
775        at: Option<String>,
776        /// Cron expression (e.g. '0 9 * * *')
777        #[arg(long = "cron")]
778        cron: Option<String>,
779        /// Clear both scheduled_for and cron_schedule
780        #[arg(long, default_value_t = false)]
781        clear: bool,
782    },
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
786pub enum InitTemplate {
787    /// Single agent, no hierarchy (1 pane)
788    Solo,
789    /// Architect + 1 engineer pair (2 panes)
790    Pair,
791    /// 1 architect + 1 manager + 3 engineers (5 panes)
792    Simple,
793    /// 1 architect + 1 manager + 5 engineers with layout (7 panes)
794    Squad,
795    /// Human + architect + 3 managers + 15 engineers with Telegram (19 panes)
796    Large,
797    /// PI + 3 sub-leads + 6 researchers — research lab style (10 panes)
798    Research,
799    /// Human + tech lead + 2 eng managers + 8 developers — full product team (11 panes)
800    Software,
801    /// Clean-room workflow: decompiler + spec-writer + test-writer + implementer (4 panes)
802    Cleanroom,
803    /// Batty self-development: human + architect + manager + 4 Rust engineers (6 panes)
804    Batty,
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
808pub enum CompletionShell {
809    Bash,
810    Zsh,
811    Fish,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
815pub enum TaskStateArg {
816    Backlog,
817    Todo,
818    #[value(name = "in-progress")]
819    InProgress,
820    Review,
821    Blocked,
822    Done,
823    Archived,
824}
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
827pub enum ReviewDispositionArg {
828    Approved,
829    #[value(name = "changes_requested")]
830    ChangesRequested,
831    Rejected,
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
835pub enum ReviewAction {
836    Approve,
837    #[value(name = "request-changes")]
838    RequestChanges,
839    Reject,
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
843pub enum AutoMergeAction {
844    Enable,
845    Disable,
846}
847
848#[derive(Subcommand, Debug)]
849pub enum ScaleCommand {
850    /// Set engineer instance count (scales up or down)
851    Engineers {
852        /// Target number of engineers per manager
853        count: u32,
854    },
855    /// Add a new manager role
856    AddManager {
857        /// Name for the new manager role
858        name: String,
859    },
860    /// Remove a manager role
861    RemoveManager {
862        /// Name of the manager role to remove
863        name: String,
864    },
865    /// Show current topology (instance counts)
866    Status,
867}
868
869#[derive(Subcommand, Debug)]
870pub enum ResearchCommand {
871    /// Start a new research mission
872    Start {
873        /// Short hypothesis or mission description
874        hypothesis: String,
875        /// Evaluator shell command to run inside the worktree
876        #[arg(long)]
877        evaluator: String,
878        /// Evaluator output contract
879        #[arg(long, value_enum, default_value_t = ResearchFormatArg::Json)]
880        format: ResearchFormatArg,
881        /// Rule for keeping or discarding candidate iterations
882        #[arg(long, value_enum, default_value_t = ResearchKeepPolicyArg::PassOnly)]
883        keep_policy: ResearchKeepPolicyArg,
884        /// Stop after this many iterations
885        #[arg(long, default_value_t = 10)]
886        max_iterations: u32,
887        /// Worktree to mutate during the mission
888        #[arg(long, default_value = ".")]
889        worktree: PathBuf,
890    },
891    /// Show the current mission status
892    Status,
893    /// Show the current mission ledger
894    Ledger,
895    /// Stop the current research mission
896    Stop,
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
900pub enum ResearchFormatArg {
901    Json,
902    #[value(name = "exit-code")]
903    ExitCode,
904}
905
906#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
907pub enum ResearchKeepPolicyArg {
908    #[value(name = "pass-only")]
909    PassOnly,
910    #[value(name = "score-improvement")]
911    ScoreImprovement,
912    #[value(name = "parity-improvement")]
913    ParityImprovement,
914}
915
916#[derive(Subcommand, Debug)]
917pub enum NudgeCommand {
918    /// Disable an intervention at runtime
919    Disable {
920        /// Intervention name
921        #[arg(value_enum)]
922        name: NudgeIntervention,
923    },
924    /// Re-enable a disabled intervention
925    Enable {
926        /// Intervention name
927        #[arg(value_enum)]
928        name: NudgeIntervention,
929    },
930    /// Show status of all interventions
931    Status,
932}
933
934#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
935pub enum NudgeIntervention {
936    Replenish,
937    Triage,
938    Review,
939    Dispatch,
940    Utilization,
941    #[value(name = "owned-task")]
942    OwnedTask,
943}
944
945#[derive(Subcommand, Debug)]
946pub enum OpenClawCommand {
947    /// Write an OpenClaw project registration/config skeleton for Batty
948    Register {
949        /// Overwrite an existing config file
950        #[arg(long, default_value_t = false)]
951        force: bool,
952    },
953    /// Show an operator-friendly Batty summary for OpenClaw supervision
954    Status {
955        /// Emit machine-readable JSON output
956        #[arg(long, default_value_t = false)]
957        json: bool,
958    },
959    /// Send a high-level instruction into an allowed Batty role
960    Instruct {
961        /// Target role, typically architect or manager
962        role: String,
963        /// High-level instruction text
964        message: String,
965    },
966    /// Export stable OpenClaw event envelopes for one project or all registered projects
967    Events {
968        /// Registered project identifier; defaults to the current project if it is registered
969        #[arg(long = "project-id", conflicts_with = "all_projects")]
970        project_id: Option<String>,
971        /// Read events across all registered projects that allow OpenClaw supervision
972        #[arg(
973            long = "all-projects",
974            default_value_t = false,
975            conflicts_with = "project_id"
976        )]
977        all_projects: bool,
978        /// Emit machine-readable JSON output
979        #[arg(long, default_value_t = false)]
980        json: bool,
981        /// Filter by stable public event topic
982        #[arg(long = "topic", value_enum)]
983        topics: Vec<OpenClawEventTopicArg>,
984        /// Filter by Batty role identifier
985        #[arg(long = "role")]
986        roles: Vec<String>,
987        /// Filter by Batty task identifier
988        #[arg(long = "task-id")]
989        task_ids: Vec<String>,
990        /// Filter by stable public event type
991        #[arg(long = "event-type")]
992        event_types: Vec<String>,
993        /// Filter by tmux/OpenClaw session name
994        #[arg(long = "session-name")]
995        session_names: Vec<String>,
996        /// Include only events at or after this unix timestamp
997        #[arg(long = "since-ts")]
998        since_ts: Option<u64>,
999        /// Trim the result set to the N most recent matching events
1000        #[arg(long)]
1001        limit: Option<usize>,
1002        /// Include archived projects when reading from the project registry
1003        #[arg(long, default_value_t = false)]
1004        include_archived: bool,
1005    },
1006    /// Run configured OpenClaw follow-up/reminder workflows
1007    #[command(name = "follow-up")]
1008    FollowUp {
1009        #[command(subcommand)]
1010        command: OpenClawFollowUpCommand,
1011    },
1012}
1013
1014#[derive(Subcommand, Debug)]
1015pub enum OpenClawFollowUpCommand {
1016    /// Evaluate configured cron follow-ups and dispatch any due reminders
1017    Run {
1018        /// Emit machine-readable JSON output
1019        #[arg(long, default_value_t = false)]
1020        json: bool,
1021    },
1022}
1023
1024#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1025pub enum OpenClawEventTopicArg {
1026    Completion,
1027    Review,
1028    Stall,
1029    Merge,
1030    Escalation,
1031    #[value(name = "delivery-failure")]
1032    DeliveryFailure,
1033    Lifecycle,
1034}
1035
1036impl NudgeIntervention {
1037    /// Return the marker file suffix for this intervention.
1038    #[allow(dead_code)]
1039    pub fn marker_name(self) -> &'static str {
1040        match self {
1041            Self::Replenish => "replenish",
1042            Self::Triage => "triage",
1043            Self::Review => "review",
1044            Self::Dispatch => "dispatch",
1045            Self::Utilization => "utilization",
1046            Self::OwnedTask => "owned-task",
1047        }
1048    }
1049
1050    /// All known interventions.
1051    #[allow(dead_code)]
1052    pub const ALL: [NudgeIntervention; 6] = [
1053        Self::Replenish,
1054        Self::Triage,
1055        Self::Review,
1056        Self::Dispatch,
1057        Self::Utilization,
1058        Self::OwnedTask,
1059    ];
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065
1066    #[test]
1067    fn board_command_defaults_to_tui() {
1068        let cli = Cli::parse_from(["batty", "board"]);
1069        match cli.command {
1070            Command::Board { command } => assert!(command.is_none()),
1071            other => panic!("expected board command, got {other:?}"),
1072        }
1073    }
1074
1075    #[test]
1076    fn board_list_subcommand_parses() {
1077        let cli = Cli::parse_from(["batty", "board", "list"]);
1078        match cli.command {
1079            Command::Board {
1080                command: Some(BoardCommand::List { status }),
1081            } => assert_eq!(status, None),
1082            other => panic!("expected board list command, got {other:?}"),
1083        }
1084    }
1085
1086    #[test]
1087    fn board_list_subcommand_parses_status_filter() {
1088        let cli = Cli::parse_from(["batty", "board", "list", "--status", "review"]);
1089        match cli.command {
1090            Command::Board {
1091                command: Some(BoardCommand::List { status }),
1092            } => assert_eq!(status.as_deref(), Some("review")),
1093            other => panic!("expected board list command, got {other:?}"),
1094        }
1095    }
1096
1097    #[test]
1098    fn board_summary_subcommand_parses() {
1099        let cli = Cli::parse_from(["batty", "board", "summary"]);
1100        match cli.command {
1101            Command::Board {
1102                command: Some(BoardCommand::Summary),
1103            } => {}
1104            other => panic!("expected board summary command, got {other:?}"),
1105        }
1106    }
1107
1108    #[test]
1109    fn board_deps_subcommand_defaults_to_tree() {
1110        let cli = Cli::parse_from(["batty", "board", "deps"]);
1111        match cli.command {
1112            Command::Board {
1113                command: Some(BoardCommand::Deps { format }),
1114            } => assert_eq!(format, DepsFormatArg::Tree),
1115            other => panic!("expected board deps command, got {other:?}"),
1116        }
1117    }
1118
1119    #[test]
1120    fn reload_subcommand_parses() {
1121        let cli = Cli::parse_from(["batty", "reload"]);
1122        match cli.command {
1123            Command::Reload => {}
1124            other => panic!("expected reload command, got {other:?}"),
1125        }
1126    }
1127
1128    #[test]
1129    fn research_start_subcommand_parses() {
1130        let cli = Cli::parse_from([
1131            "batty",
1132            "research",
1133            "start",
1134            "improve baseline",
1135            "--evaluator",
1136            "cargo test",
1137            "--format",
1138            "exit-code",
1139            "--keep-policy",
1140            "parity-improvement",
1141            "--max-iterations",
1142            "3",
1143            "--worktree",
1144            "tmp/research",
1145        ]);
1146        match cli.command {
1147            Command::Research {
1148                command:
1149                    ResearchCommand::Start {
1150                        hypothesis,
1151                        evaluator,
1152                        format,
1153                        keep_policy,
1154                        max_iterations,
1155                        worktree,
1156                    },
1157            } => {
1158                assert_eq!(hypothesis, "improve baseline");
1159                assert_eq!(evaluator, "cargo test");
1160                assert_eq!(format, ResearchFormatArg::ExitCode);
1161                assert_eq!(keep_policy, ResearchKeepPolicyArg::ParityImprovement);
1162                assert_eq!(max_iterations, 3);
1163                assert_eq!(worktree, PathBuf::from("tmp/research"));
1164            }
1165            other => panic!("expected research start command, got {other:?}"),
1166        }
1167    }
1168
1169    #[test]
1170    fn research_status_subcommand_parses() {
1171        let cli = Cli::parse_from(["batty", "research", "status"]);
1172        match cli.command {
1173            Command::Research {
1174                command: ResearchCommand::Status,
1175            } => {}
1176            other => panic!("expected research status command, got {other:?}"),
1177        }
1178    }
1179
1180    #[test]
1181    fn board_deps_subcommand_parses_format_flag() {
1182        for (arg, expected) in [
1183            ("tree", DepsFormatArg::Tree),
1184            ("flat", DepsFormatArg::Flat),
1185            ("dot", DepsFormatArg::Dot),
1186        ] {
1187            let cli = Cli::parse_from(["batty", "board", "deps", "--format", arg]);
1188            match cli.command {
1189                Command::Board {
1190                    command: Some(BoardCommand::Deps { format }),
1191                } => assert_eq!(format, expected, "format arg={arg}"),
1192                other => panic!("expected board deps command for {arg}, got {other:?}"),
1193            }
1194        }
1195    }
1196
1197    #[test]
1198    fn board_archive_subcommand_parses() {
1199        let cli = Cli::parse_from(["batty", "board", "archive"]);
1200        match cli.command {
1201            Command::Board {
1202                command:
1203                    Some(BoardCommand::Archive {
1204                        older_than,
1205                        dry_run,
1206                    }),
1207            } => {
1208                assert_eq!(older_than, "0s");
1209                assert!(!dry_run);
1210            }
1211            other => panic!("expected board archive command, got {other:?}"),
1212        }
1213    }
1214
1215    #[test]
1216    fn board_archive_subcommand_parses_older_than() {
1217        let cli = Cli::parse_from(["batty", "board", "archive", "--older-than", "7d"]);
1218        match cli.command {
1219            Command::Board {
1220                command:
1221                    Some(BoardCommand::Archive {
1222                        older_than,
1223                        dry_run,
1224                    }),
1225            } => {
1226                assert_eq!(older_than, "7d");
1227                assert!(!dry_run);
1228            }
1229            other => panic!("expected board archive command with older_than, got {other:?}"),
1230        }
1231    }
1232
1233    #[test]
1234    fn board_archive_subcommand_parses_dry_run() {
1235        let cli = Cli::parse_from(["batty", "board", "archive", "--dry-run"]);
1236        match cli.command {
1237            Command::Board {
1238                command:
1239                    Some(BoardCommand::Archive {
1240                        older_than,
1241                        dry_run,
1242                    }),
1243            } => {
1244                assert_eq!(older_than, "0s");
1245                assert!(dry_run);
1246            }
1247            other => panic!("expected board archive command with dry_run, got {other:?}"),
1248        }
1249    }
1250
1251    #[test]
1252    fn board_health_subcommand_parses() {
1253        let cli = Cli::parse_from(["batty", "board", "health"]);
1254        match cli.command {
1255            Command::Board {
1256                command: Some(BoardCommand::Health),
1257            } => {}
1258            other => panic!("expected board health command, got {other:?}"),
1259        }
1260    }
1261
1262    #[test]
1263    fn init_subcommand_defaults_to_simple() {
1264        let cli = Cli::parse_from(["batty", "init"]);
1265        match cli.command {
1266            Command::Init {
1267                template,
1268                from,
1269                agent,
1270                ..
1271            } => {
1272                assert_eq!(template, None);
1273                assert_eq!(from, None);
1274                assert_eq!(agent, None);
1275            }
1276            other => panic!("expected init command, got {other:?}"),
1277        }
1278    }
1279
1280    #[test]
1281    fn init_subcommand_accepts_large_template() {
1282        let cli = Cli::parse_from(["batty", "init", "--template", "large"]);
1283        match cli.command {
1284            Command::Init { template, from, .. } => {
1285                assert_eq!(template, Some(InitTemplate::Large));
1286                assert_eq!(from, None);
1287            }
1288            other => panic!("expected init command, got {other:?}"),
1289        }
1290    }
1291
1292    #[test]
1293    fn init_subcommand_accepts_from_template_name() {
1294        let cli = Cli::parse_from(["batty", "init", "--from", "custom-team"]);
1295        match cli.command {
1296            Command::Init { template, from, .. } => {
1297                assert_eq!(template, None);
1298                assert_eq!(from.as_deref(), Some("custom-team"));
1299            }
1300            other => panic!("expected init command, got {other:?}"),
1301        }
1302    }
1303
1304    #[test]
1305    fn init_subcommand_rejects_from_with_template() {
1306        let result = Cli::try_parse_from(["batty", "init", "--template", "large", "--from", "x"]);
1307        assert!(result.is_err());
1308    }
1309
1310    #[test]
1311    fn init_agent_flag_parses() {
1312        let cli = Cli::parse_from(["batty", "init", "--agent", "codex"]);
1313        match cli.command {
1314            Command::Init { agent, .. } => {
1315                assert_eq!(agent.as_deref(), Some("codex"));
1316            }
1317            other => panic!("expected init command, got {other:?}"),
1318        }
1319    }
1320
1321    #[test]
1322    fn install_alias_maps_to_init() {
1323        let cli = Cli::parse_from(["batty", "install"]);
1324        match cli.command {
1325            Command::Init {
1326                template,
1327                from,
1328                agent,
1329                ..
1330            } => {
1331                assert_eq!(template, None);
1332                assert_eq!(from, None);
1333                assert_eq!(agent, None);
1334            }
1335            other => panic!("expected init command via install alias, got {other:?}"),
1336        }
1337    }
1338
1339    #[test]
1340    fn install_alias_with_agent_flag() {
1341        let cli = Cli::parse_from(["batty", "install", "--agent", "kiro"]);
1342        match cli.command {
1343            Command::Init { agent, .. } => {
1344                assert_eq!(agent.as_deref(), Some("kiro"));
1345            }
1346            other => panic!("expected init command via install alias, got {other:?}"),
1347        }
1348    }
1349
1350    #[test]
1351    fn export_template_subcommand_parses() {
1352        let cli = Cli::parse_from(["batty", "export-template", "myteam"]);
1353        match cli.command {
1354            Command::ExportTemplate { name } => assert_eq!(name, "myteam"),
1355            other => panic!("expected export-template command, got {other:?}"),
1356        }
1357    }
1358
1359    #[test]
1360    fn export_run_subcommand_parses() {
1361        let cli = Cli::parse_from(["batty", "export-run"]);
1362        match cli.command {
1363            Command::ExportRun => {}
1364            other => panic!("expected export-run command, got {other:?}"),
1365        }
1366    }
1367
1368    #[test]
1369    fn retro_subcommand_parses() {
1370        let cli = Cli::parse_from(["batty", "retro"]);
1371        match cli.command {
1372            Command::Retro { events } => assert!(events.is_none()),
1373            other => panic!("expected retro command, got {other:?}"),
1374        }
1375    }
1376
1377    #[test]
1378    fn retro_subcommand_parses_with_events_path() {
1379        let cli = Cli::parse_from(["batty", "retro", "--events", "/tmp/events.jsonl"]);
1380        match cli.command {
1381            Command::Retro { events } => {
1382                assert_eq!(events, Some(PathBuf::from("/tmp/events.jsonl")));
1383            }
1384            other => panic!("expected retro command, got {other:?}"),
1385        }
1386    }
1387
1388    #[test]
1389    fn start_subcommand_defaults() {
1390        let cli = Cli::parse_from(["batty", "start"]);
1391        match cli.command {
1392            Command::Start { attach } => assert!(!attach),
1393            other => panic!("expected start command, got {other:?}"),
1394        }
1395    }
1396
1397    #[test]
1398    fn start_subcommand_with_attach() {
1399        let cli = Cli::parse_from(["batty", "start", "--attach"]);
1400        match cli.command {
1401            Command::Start { attach } => assert!(attach),
1402            other => panic!("expected start command, got {other:?}"),
1403        }
1404    }
1405
1406    #[test]
1407    fn stop_subcommand_parses() {
1408        let cli = Cli::parse_from(["batty", "stop"]);
1409        assert!(matches!(cli.command, Command::Stop));
1410    }
1411
1412    #[test]
1413    fn attach_subcommand_parses() {
1414        let cli = Cli::parse_from(["batty", "attach"]);
1415        assert!(matches!(cli.command, Command::Attach));
1416    }
1417
1418    #[test]
1419    fn status_subcommand_defaults() {
1420        let cli = Cli::parse_from(["batty", "status"]);
1421        match cli.command {
1422            Command::Status {
1423                json,
1424                detail,
1425                health,
1426            } => {
1427                assert!(!json);
1428                assert!(!detail);
1429                assert!(!health);
1430            }
1431            other => panic!("expected status command, got {other:?}"),
1432        }
1433    }
1434
1435    #[test]
1436    fn status_subcommand_json_flag() {
1437        let cli = Cli::parse_from(["batty", "status", "--json"]);
1438        match cli.command {
1439            Command::Status {
1440                json,
1441                detail,
1442                health,
1443            } => {
1444                assert!(json);
1445                assert!(!detail);
1446                assert!(!health);
1447            }
1448            other => panic!("expected status command, got {other:?}"),
1449        }
1450    }
1451
1452    #[test]
1453    fn status_subcommand_detail_flag() {
1454        let cli = Cli::parse_from(["batty", "status", "--detail"]);
1455        match cli.command {
1456            Command::Status {
1457                json,
1458                detail,
1459                health,
1460            } => {
1461                assert!(!json);
1462                assert!(detail);
1463                assert!(!health);
1464            }
1465            other => panic!("expected status command, got {other:?}"),
1466        }
1467    }
1468
1469    #[test]
1470    fn status_subcommand_health_flag() {
1471        let cli = Cli::parse_from(["batty", "status", "--health"]);
1472        match cli.command {
1473            Command::Status {
1474                json,
1475                detail,
1476                health,
1477            } => {
1478                assert!(!json);
1479                assert!(!detail);
1480                assert!(health);
1481            }
1482            other => panic!("expected status command, got {other:?}"),
1483        }
1484    }
1485
1486    #[test]
1487    fn openclaw_register_subcommand_parses() {
1488        let cli = Cli::parse_from(["batty", "openclaw", "register", "--force"]);
1489        match cli.command {
1490            Command::OpenClaw { command } => match command {
1491                OpenClawCommand::Register { force } => assert!(force),
1492                other => panic!("expected openclaw register command, got {other:?}"),
1493            },
1494            other => panic!("expected openclaw command, got {other:?}"),
1495        }
1496    }
1497
1498    #[test]
1499    fn openclaw_status_subcommand_parses_json_flag() {
1500        let cli = Cli::parse_from(["batty", "openclaw", "status", "--json"]);
1501        match cli.command {
1502            Command::OpenClaw { command } => match command {
1503                OpenClawCommand::Status { json } => assert!(json),
1504                other => panic!("expected openclaw status command, got {other:?}"),
1505            },
1506            other => panic!("expected openclaw command, got {other:?}"),
1507        }
1508    }
1509
1510    #[test]
1511    fn openclaw_follow_up_run_subcommand_parses() {
1512        let cli = Cli::parse_from(["batty", "openclaw", "follow-up", "run"]);
1513        match cli.command {
1514            Command::OpenClaw { command } => match command {
1515                OpenClawCommand::FollowUp { command } => match command {
1516                    OpenClawFollowUpCommand::Run { json } => assert!(!json),
1517                },
1518                other => panic!("expected openclaw follow-up command, got {other:?}"),
1519            },
1520            other => panic!("expected openclaw command, got {other:?}"),
1521        }
1522    }
1523
1524    #[test]
1525    fn openclaw_events_subcommand_parses_filters() {
1526        let cli = Cli::parse_from([
1527            "batty",
1528            "openclaw",
1529            "events",
1530            "--project-id",
1531            "fixture-degraded",
1532            "--json",
1533            "--topic",
1534            "escalation",
1535            "--topic",
1536            "completion",
1537            "--role",
1538            "eng-1-1",
1539            "--task-id",
1540            "449",
1541            "--event-type",
1542            "task.escalated",
1543            "--session-name",
1544            "batty-fixture-team-degraded",
1545            "--since-ts",
1546            "1712402000",
1547            "--limit",
1548            "5",
1549            "--include-archived",
1550        ]);
1551        match cli.command {
1552            Command::OpenClaw { command } => match command {
1553                OpenClawCommand::Events {
1554                    project_id,
1555                    all_projects,
1556                    json,
1557                    topics,
1558                    roles,
1559                    task_ids,
1560                    event_types,
1561                    session_names,
1562                    since_ts,
1563                    limit,
1564                    include_archived,
1565                } => {
1566                    assert_eq!(project_id.as_deref(), Some("fixture-degraded"));
1567                    assert!(!all_projects);
1568                    assert!(json);
1569                    assert_eq!(
1570                        topics,
1571                        vec![
1572                            OpenClawEventTopicArg::Escalation,
1573                            OpenClawEventTopicArg::Completion,
1574                        ]
1575                    );
1576                    assert_eq!(roles, vec!["eng-1-1"]);
1577                    assert_eq!(task_ids, vec!["449"]);
1578                    assert_eq!(event_types, vec!["task.escalated"]);
1579                    assert_eq!(session_names, vec!["batty-fixture-team-degraded"]);
1580                    assert_eq!(since_ts, Some(1_712_402_000));
1581                    assert_eq!(limit, Some(5));
1582                    assert!(include_archived);
1583                }
1584                other => panic!("expected openclaw events command, got {other:?}"),
1585            },
1586            other => panic!("expected openclaw command, got {other:?}"),
1587        }
1588    }
1589
1590    #[test]
1591    fn send_subcommand_parses_role_and_message() {
1592        let cli = Cli::parse_from(["batty", "send", "architect", "hello world"]);
1593        match cli.command {
1594            Command::Send {
1595                from,
1596                role,
1597                message,
1598            } => {
1599                assert!(from.is_none());
1600                assert_eq!(role, "architect");
1601                assert_eq!(message, "hello world");
1602            }
1603            other => panic!("expected send command, got {other:?}"),
1604        }
1605    }
1606
1607    #[test]
1608    fn assign_subcommand_parses_engineer_and_task() {
1609        let cli = Cli::parse_from(["batty", "assign", "eng-1-1", "fix auth bug"]);
1610        match cli.command {
1611            Command::Assign { engineer, task } => {
1612                assert_eq!(engineer, "eng-1-1");
1613                assert_eq!(task, "fix auth bug");
1614            }
1615            other => panic!("expected assign command, got {other:?}"),
1616        }
1617    }
1618
1619    #[test]
1620    fn bench_subcommand_parses_reason() {
1621        let cli = Cli::parse_from(["batty", "bench", "eng-1-1", "--reason", "session end"]);
1622        match cli.command {
1623            Command::Bench { engineer, reason } => {
1624                assert_eq!(engineer, "eng-1-1");
1625                assert_eq!(reason.as_deref(), Some("session end"));
1626            }
1627            other => panic!("expected bench command, got {other:?}"),
1628        }
1629    }
1630
1631    #[test]
1632    fn unbench_subcommand_parses_engineer() {
1633        let cli = Cli::parse_from(["batty", "unbench", "eng-1-1"]);
1634        match cli.command {
1635            Command::Unbench { engineer } => assert_eq!(engineer, "eng-1-1"),
1636            other => panic!("expected unbench command, got {other:?}"),
1637        }
1638    }
1639
1640    #[test]
1641    fn validate_subcommand_parses() {
1642        let cli = Cli::parse_from(["batty", "validate"]);
1643        match cli.command {
1644            Command::Validate { show_checks } => assert!(!show_checks),
1645            other => panic!("expected validate command, got {other:?}"),
1646        }
1647    }
1648
1649    #[test]
1650    fn validate_subcommand_show_checks_flag() {
1651        let cli = Cli::parse_from(["batty", "validate", "--show-checks"]);
1652        match cli.command {
1653            Command::Validate { show_checks } => assert!(show_checks),
1654            other => panic!("expected validate command with show_checks, got {other:?}"),
1655        }
1656    }
1657
1658    #[test]
1659    fn config_subcommand_json_flag() {
1660        let cli = Cli::parse_from(["batty", "config", "--json"]);
1661        match cli.command {
1662            Command::Config { json } => assert!(json),
1663            other => panic!("expected config command, got {other:?}"),
1664        }
1665    }
1666
1667    #[test]
1668    fn merge_subcommand_parses_engineer() {
1669        let cli = Cli::parse_from(["batty", "merge", "eng-1-1"]);
1670        match cli.command {
1671            Command::Merge { engineer } => assert_eq!(engineer, "eng-1-1"),
1672            other => panic!("expected merge command, got {other:?}"),
1673        }
1674    }
1675
1676    #[test]
1677    fn completions_subcommand_parses_shell() {
1678        let cli = Cli::parse_from(["batty", "completions", "zsh"]);
1679        match cli.command {
1680            Command::Completions { shell } => assert_eq!(shell, CompletionShell::Zsh),
1681            other => panic!("expected completions command, got {other:?}"),
1682        }
1683    }
1684
1685    #[test]
1686    fn inbox_subcommand_parses_defaults() {
1687        let cli = Cli::parse_from(["batty", "inbox", "architect"]);
1688        match cli.command {
1689            Command::Inbox {
1690                command,
1691                member,
1692                limit,
1693                all,
1694                raw,
1695            } => {
1696                assert!(command.is_none());
1697                assert_eq!(member.as_deref(), Some("architect"));
1698                assert_eq!(limit, 20);
1699                assert!(!all);
1700                assert!(!raw);
1701            }
1702            other => panic!("expected inbox command, got {other:?}"),
1703        }
1704    }
1705
1706    #[test]
1707    fn inbox_subcommand_parses_limit_flag() {
1708        let cli = Cli::parse_from(["batty", "inbox", "architect", "-n", "50"]);
1709        match cli.command {
1710            Command::Inbox {
1711                command,
1712                member,
1713                limit,
1714                all,
1715                raw,
1716            } => {
1717                assert!(command.is_none());
1718                assert_eq!(member.as_deref(), Some("architect"));
1719                assert_eq!(limit, 50);
1720                assert!(!all);
1721                assert!(!raw);
1722            }
1723            other => panic!("expected inbox command, got {other:?}"),
1724        }
1725    }
1726
1727    #[test]
1728    fn inbox_subcommand_parses_all_flag() {
1729        let cli = Cli::parse_from(["batty", "inbox", "architect", "--all"]);
1730        match cli.command {
1731            Command::Inbox {
1732                command,
1733                member,
1734                limit,
1735                all,
1736                raw,
1737            } => {
1738                assert!(command.is_none());
1739                assert_eq!(member.as_deref(), Some("architect"));
1740                assert_eq!(limit, 20);
1741                assert!(all);
1742                assert!(!raw);
1743            }
1744            other => panic!("expected inbox command, got {other:?}"),
1745        }
1746    }
1747
1748    #[test]
1749    fn inbox_subcommand_parses_raw_flag() {
1750        let cli = Cli::parse_from(["batty", "inbox", "architect", "--raw"]);
1751        match cli.command {
1752            Command::Inbox {
1753                command,
1754                member,
1755                raw,
1756                ..
1757            } => {
1758                assert!(command.is_none());
1759                assert_eq!(member.as_deref(), Some("architect"));
1760                assert!(raw);
1761            }
1762            other => panic!("expected inbox command, got {other:?}"),
1763        }
1764    }
1765
1766    #[test]
1767    fn inbox_purge_subcommand_parses_role_and_before() {
1768        let cli = Cli::parse_from(["batty", "inbox", "purge", "architect", "--before", "123"]);
1769        match cli.command {
1770            Command::Inbox {
1771                command:
1772                    Some(InboxCommand::Purge {
1773                        role,
1774                        all_roles,
1775                        before,
1776                        older_than,
1777                        all,
1778                    }),
1779                member,
1780                ..
1781            } => {
1782                assert!(member.is_none());
1783                assert_eq!(role.as_deref(), Some("architect"));
1784                assert!(!all_roles);
1785                assert_eq!(before, Some(123));
1786                assert!(older_than.is_none());
1787                assert!(!all);
1788            }
1789            other => panic!("expected inbox purge command, got {other:?}"),
1790        }
1791    }
1792
1793    #[test]
1794    fn inbox_purge_subcommand_parses_all_roles_and_all() {
1795        let cli = Cli::parse_from(["batty", "inbox", "purge", "--all-roles", "--all"]);
1796        match cli.command {
1797            Command::Inbox {
1798                command:
1799                    Some(InboxCommand::Purge {
1800                        role,
1801                        all_roles,
1802                        before,
1803                        older_than,
1804                        all,
1805                    }),
1806                member,
1807                ..
1808            } => {
1809                assert!(member.is_none());
1810                assert!(role.is_none());
1811                assert!(all_roles);
1812                assert_eq!(before, None);
1813                assert!(older_than.is_none());
1814                assert!(all);
1815            }
1816            other => panic!("expected inbox purge command, got {other:?}"),
1817        }
1818    }
1819
1820    #[test]
1821    fn inbox_purge_subcommand_parses_older_than() {
1822        let cli = Cli::parse_from(["batty", "inbox", "purge", "eng-1", "--older-than", "24h"]);
1823        match cli.command {
1824            Command::Inbox {
1825                command:
1826                    Some(InboxCommand::Purge {
1827                        role,
1828                        all_roles,
1829                        before,
1830                        older_than,
1831                        all,
1832                    }),
1833                ..
1834            } => {
1835                assert_eq!(role.as_deref(), Some("eng-1"));
1836                assert!(!all_roles);
1837                assert_eq!(before, None);
1838                assert_eq!(older_than.as_deref(), Some("24h"));
1839                assert!(!all);
1840            }
1841            other => panic!("expected inbox purge command, got {other:?}"),
1842        }
1843    }
1844
1845    #[test]
1846    fn inbox_purge_rejects_older_than_with_before() {
1847        let result = Cli::try_parse_from([
1848            "batty",
1849            "inbox",
1850            "purge",
1851            "eng-1",
1852            "--older-than",
1853            "24h",
1854            "--before",
1855            "100",
1856        ]);
1857        assert!(result.is_err());
1858    }
1859
1860    #[test]
1861    fn inbox_purge_rejects_older_than_with_all() {
1862        let result = Cli::try_parse_from([
1863            "batty",
1864            "inbox",
1865            "purge",
1866            "eng-1",
1867            "--older-than",
1868            "24h",
1869            "--all",
1870        ]);
1871        assert!(result.is_err());
1872    }
1873
1874    #[test]
1875    fn read_subcommand_parses_member_and_id() {
1876        let cli = Cli::parse_from(["batty", "read", "architect", "abc123"]);
1877        match cli.command {
1878            Command::Read { member, id } => {
1879                assert_eq!(member, "architect");
1880                assert_eq!(id, "abc123");
1881            }
1882            other => panic!("expected read command, got {other:?}"),
1883        }
1884    }
1885
1886    #[test]
1887    fn ack_subcommand_parses_member_and_id() {
1888        let cli = Cli::parse_from(["batty", "ack", "eng-1-1", "abc123"]);
1889        match cli.command {
1890            Command::Ack { member, id } => {
1891                assert_eq!(member, "eng-1-1");
1892                assert_eq!(id, "abc123");
1893            }
1894            other => panic!("expected ack command, got {other:?}"),
1895        }
1896    }
1897
1898    #[test]
1899    fn pause_subcommand_parses() {
1900        let cli = Cli::parse_from(["batty", "pause"]);
1901        assert!(matches!(cli.command, Command::Pause));
1902    }
1903
1904    #[test]
1905    fn resume_subcommand_parses() {
1906        let cli = Cli::parse_from(["batty", "resume"]);
1907        assert!(matches!(cli.command, Command::Resume));
1908    }
1909
1910    #[test]
1911    fn telegram_subcommand_parses() {
1912        let cli = Cli::parse_from(["batty", "telegram"]);
1913        assert!(matches!(cli.command, Command::Telegram));
1914    }
1915
1916    #[test]
1917    fn discord_subcommand_defaults_to_setup_flow() {
1918        let cli = Cli::parse_from(["batty", "discord"]);
1919        assert!(matches!(cli.command, Command::Discord { command: None }));
1920    }
1921
1922    #[test]
1923    fn discord_status_subcommand_parses() {
1924        let cli = Cli::parse_from(["batty", "discord", "status"]);
1925        assert!(matches!(
1926            cli.command,
1927            Command::Discord {
1928                command: Some(DiscordCommand::Status)
1929            }
1930        ));
1931    }
1932
1933    #[test]
1934    fn project_register_subcommand_parses() {
1935        let cli = Cli::parse_from([
1936            "batty",
1937            "project",
1938            "register",
1939            "--project-id",
1940            "batty-core",
1941            "--name",
1942            "Batty Core",
1943            "--project-root",
1944            "/tmp/batty",
1945            "--board-dir",
1946            "/tmp/batty/.batty/team_config/board",
1947            "--team-name",
1948            "batty",
1949            "--session-name",
1950            "batty-batty",
1951            "--owner",
1952            "platform",
1953            "--tag",
1954            "openclaw",
1955            "--channel-binding",
1956            "telegram=chat:123",
1957            "--allow-openclaw-supervision",
1958            "--json",
1959        ]);
1960        match cli.command {
1961            Command::Project {
1962                command:
1963                    ProjectCommand::Register {
1964                        project_id,
1965                        name,
1966                        aliases,
1967                        project_root,
1968                        board_dir,
1969                        team_name,
1970                        session_name,
1971                        owner,
1972                        tags,
1973                        channel_bindings,
1974                        thread_bindings,
1975                        allow_openclaw_supervision,
1976                        allow_cross_project_routing,
1977                        allow_shared_service_routing,
1978                        archived,
1979                        json,
1980                    },
1981            } => {
1982                assert_eq!(project_id, "batty-core");
1983                assert_eq!(name, "Batty Core");
1984                assert_eq!(project_root, PathBuf::from("/tmp/batty"));
1985                assert_eq!(
1986                    board_dir,
1987                    PathBuf::from("/tmp/batty/.batty/team_config/board")
1988                );
1989                assert_eq!(team_name, "batty");
1990                assert_eq!(session_name, "batty-batty");
1991                assert_eq!(owner.as_deref(), Some("platform"));
1992                assert!(aliases.is_empty());
1993                assert_eq!(tags, vec!["openclaw"]);
1994                assert_eq!(channel_bindings, vec!["telegram=chat:123"]);
1995                assert!(thread_bindings.is_empty());
1996                assert!(allow_openclaw_supervision);
1997                assert!(!allow_cross_project_routing);
1998                assert!(!allow_shared_service_routing);
1999                assert!(!archived);
2000                assert!(json);
2001            }
2002            other => panic!("expected project register command, got {other:?}"),
2003        }
2004    }
2005
2006    #[test]
2007    fn project_get_subcommand_parses() {
2008        let cli = Cli::parse_from(["batty", "project", "get", "batty-core", "--json"]);
2009        match cli.command {
2010            Command::Project {
2011                command: ProjectCommand::Get { project_id, json },
2012            } => {
2013                assert_eq!(project_id, "batty-core");
2014                assert!(json);
2015            }
2016            other => panic!("expected project get command, got {other:?}"),
2017        }
2018    }
2019
2020    #[test]
2021    fn project_lifecycle_subcommands_parse() {
2022        let start = Cli::parse_from(["batty", "project", "start", "batty-core", "--json"]);
2023        assert!(matches!(
2024            start.command,
2025            Command::Project {
2026                command: ProjectCommand::Start {
2027                    project_id,
2028                    json: true
2029                }
2030            } if project_id == "batty-core"
2031        ));
2032
2033        let stop = Cli::parse_from(["batty", "project", "stop", "batty-core"]);
2034        assert!(matches!(
2035            stop.command,
2036            Command::Project {
2037                command: ProjectCommand::Stop {
2038                    project_id,
2039                    json: false
2040                }
2041            } if project_id == "batty-core"
2042        ));
2043
2044        let restart = Cli::parse_from(["batty", "project", "restart", "batty-core"]);
2045        assert!(matches!(
2046            restart.command,
2047            Command::Project {
2048                command: ProjectCommand::Restart {
2049                    project_id,
2050                    json: false
2051                }
2052            } if project_id == "batty-core"
2053        ));
2054
2055        let status = Cli::parse_from(["batty", "project", "status", "batty-core"]);
2056        assert!(matches!(
2057            status.command,
2058            Command::Project {
2059                command: ProjectCommand::Status {
2060                    project_id,
2061                    json: false
2062                }
2063            } if project_id == "batty-core"
2064        ));
2065    }
2066
2067    #[test]
2068    fn project_set_active_subcommand_parses_thread_scope() {
2069        let cli = Cli::parse_from([
2070            "batty",
2071            "project",
2072            "set-active",
2073            "batty-core",
2074            "--channel",
2075            "slack",
2076            "--binding",
2077            "channel:C123",
2078            "--thread-binding",
2079            "thread:abc",
2080        ]);
2081        match cli.command {
2082            Command::Project {
2083                command:
2084                    ProjectCommand::SetActive {
2085                        project_id,
2086                        channel,
2087                        binding,
2088                        thread_binding,
2089                        json,
2090                    },
2091            } => {
2092                assert_eq!(project_id, "batty-core");
2093                assert_eq!(channel.as_deref(), Some("slack"));
2094                assert_eq!(binding.as_deref(), Some("channel:C123"));
2095                assert_eq!(thread_binding.as_deref(), Some("thread:abc"));
2096                assert!(!json);
2097            }
2098            other => panic!("expected project set-active command, got {other:?}"),
2099        }
2100    }
2101
2102    #[test]
2103    fn project_resolve_subcommand_parses() {
2104        let cli = Cli::parse_from([
2105            "batty",
2106            "project",
2107            "resolve",
2108            "check batty",
2109            "--channel",
2110            "telegram",
2111            "--binding",
2112            "chat:123",
2113            "--json",
2114        ]);
2115        match cli.command {
2116            Command::Project {
2117                command:
2118                    ProjectCommand::Resolve {
2119                        message,
2120                        channel,
2121                        binding,
2122                        thread_binding,
2123                        json,
2124                    },
2125            } => {
2126                assert_eq!(message, "check batty");
2127                assert_eq!(channel.as_deref(), Some("telegram"));
2128                assert_eq!(binding.as_deref(), Some("chat:123"));
2129                assert!(thread_binding.is_none());
2130                assert!(json);
2131            }
2132            other => panic!("expected project resolve command, got {other:?}"),
2133        }
2134    }
2135
2136    #[test]
2137    fn doctor_subcommand_parses() {
2138        let cli = Cli::parse_from(["batty", "doctor"]);
2139        assert!(matches!(
2140            cli.command,
2141            Command::Doctor {
2142                fix: false,
2143                yes: false
2144            }
2145        ));
2146    }
2147
2148    #[test]
2149    fn doctor_subcommand_parses_fix_flag() {
2150        let cli = Cli::parse_from(["batty", "doctor", "--fix"]);
2151        assert!(matches!(
2152            cli.command,
2153            Command::Doctor {
2154                fix: true,
2155                yes: false
2156            }
2157        ));
2158    }
2159
2160    #[test]
2161    fn doctor_subcommand_parses_fix_yes_flags() {
2162        let cli = Cli::parse_from(["batty", "doctor", "--fix", "--yes"]);
2163        assert!(matches!(
2164            cli.command,
2165            Command::Doctor {
2166                fix: true,
2167                yes: true
2168            }
2169        ));
2170    }
2171
2172    #[test]
2173    fn worktree_health_command_parses() {
2174        let cli = Cli::parse_from(["batty", "worktree", "--health"]);
2175        assert!(matches!(cli.command, Command::Worktree { health: true }));
2176    }
2177
2178    #[test]
2179    fn load_subcommand_parses() {
2180        let cli = Cli::parse_from(["batty", "load"]);
2181        assert!(matches!(cli.command, Command::Load));
2182    }
2183
2184    #[test]
2185    fn parity_subcommand_parses_defaults() {
2186        let cli = Cli::parse_from(["batty", "parity"]);
2187        match cli.command {
2188            Command::Parity { detail, gaps } => {
2189                assert!(!detail);
2190                assert!(!gaps);
2191            }
2192            other => panic!("expected parity command, got {other:?}"),
2193        }
2194    }
2195
2196    #[test]
2197    fn parity_subcommand_parses_gaps_flag() {
2198        let cli = Cli::parse_from(["batty", "parity", "--gaps"]);
2199        match cli.command {
2200            Command::Parity { detail, gaps } => {
2201                assert!(!detail);
2202                assert!(gaps);
2203            }
2204            other => panic!("expected parity command, got {other:?}"),
2205        }
2206    }
2207
2208    #[test]
2209    fn verify_subcommand_parses() {
2210        let cli = Cli::parse_from(["batty", "verify"]);
2211        assert!(matches!(cli.command, Command::Verify));
2212    }
2213
2214    #[test]
2215    fn release_subcommand_parses_defaults() {
2216        let cli = Cli::parse_from(["batty", "release"]);
2217        match cli.command {
2218            Command::Release { tag } => assert!(tag.is_none()),
2219            other => panic!("expected release command, got {other:?}"),
2220        }
2221    }
2222
2223    #[test]
2224    fn release_subcommand_parses_tag_override() {
2225        let cli = Cli::parse_from(["batty", "release", "--tag", "batty-2026-04-10"]);
2226        match cli.command {
2227            Command::Release { tag } => {
2228                assert_eq!(tag.as_deref(), Some("batty-2026-04-10"))
2229            }
2230            other => panic!("expected release command, got {other:?}"),
2231        }
2232    }
2233
2234    #[test]
2235    fn queue_subcommand_parses() {
2236        let cli = Cli::parse_from(["batty", "queue"]);
2237        assert!(matches!(cli.command, Command::Queue));
2238    }
2239
2240    #[test]
2241    fn dispatch_explain_subcommand_parses() {
2242        let cli = Cli::parse_from(["batty", "dispatch", "--explain", "--task", "42"]);
2243        match cli.command {
2244            Command::Dispatch { explain, task } => {
2245                assert!(explain);
2246                assert_eq!(task, Some(42));
2247            }
2248            other => panic!("expected dispatch command, got {other:?}"),
2249        }
2250    }
2251
2252    #[test]
2253    fn cost_subcommand_parses() {
2254        let cli = Cli::parse_from(["batty", "cost"]);
2255        assert!(matches!(cli.command, Command::Cost));
2256    }
2257
2258    #[test]
2259    fn verbose_flag_is_global() {
2260        let cli = Cli::parse_from(["batty", "-vv", "status"]);
2261        assert_eq!(cli.verbose, 2);
2262    }
2263
2264    #[test]
2265    fn task_transition_subcommand_parses() {
2266        let cli = Cli::parse_from(["batty", "task", "transition", "24", "in-progress"]);
2267        match cli.command {
2268            Command::Task {
2269                command:
2270                    TaskCommand::Transition {
2271                        task_id,
2272                        target_state,
2273                    },
2274            } => {
2275                assert_eq!(task_id, 24);
2276                assert_eq!(target_state, TaskStateArg::InProgress);
2277            }
2278            other => panic!("expected task transition command, got {other:?}"),
2279        }
2280    }
2281
2282    #[test]
2283    fn task_assign_subcommand_parses() {
2284        let cli = Cli::parse_from([
2285            "batty",
2286            "task",
2287            "assign",
2288            "24",
2289            "--execution-owner",
2290            "eng-1-2",
2291            "--review-owner",
2292            "manager-1",
2293        ]);
2294        match cli.command {
2295            Command::Task {
2296                command:
2297                    TaskCommand::Assign {
2298                        task_id,
2299                        execution_owner,
2300                        review_owner,
2301                    },
2302            } => {
2303                assert_eq!(task_id, 24);
2304                assert_eq!(execution_owner.as_deref(), Some("eng-1-2"));
2305                assert_eq!(review_owner.as_deref(), Some("manager-1"));
2306            }
2307            other => panic!("expected task assign command, got {other:?}"),
2308        }
2309    }
2310
2311    #[test]
2312    fn task_review_subcommand_parses() {
2313        let cli = Cli::parse_from([
2314            "batty",
2315            "task",
2316            "review",
2317            "24",
2318            "--disposition",
2319            "changes_requested",
2320        ]);
2321        match cli.command {
2322            Command::Task {
2323                command:
2324                    TaskCommand::Review {
2325                        task_id,
2326                        disposition,
2327                        feedback,
2328                    },
2329            } => {
2330                assert_eq!(task_id, 24);
2331                assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
2332                assert!(feedback.is_none());
2333            }
2334            other => panic!("expected task review command, got {other:?}"),
2335        }
2336    }
2337
2338    #[test]
2339    fn task_update_subcommand_parses() {
2340        let cli = Cli::parse_from([
2341            "batty",
2342            "task",
2343            "update",
2344            "24",
2345            "--branch",
2346            "eng-1-2/task-24",
2347            "--commit",
2348            "abc1234",
2349            "--blocked-on",
2350            "waiting for review",
2351            "--clear-blocked",
2352        ]);
2353        match cli.command {
2354            Command::Task {
2355                command:
2356                    TaskCommand::Update {
2357                        task_id,
2358                        branch,
2359                        commit,
2360                        blocked_on,
2361                        clear_blocked,
2362                    },
2363            } => {
2364                assert_eq!(task_id, 24);
2365                assert_eq!(branch.as_deref(), Some("eng-1-2/task-24"));
2366                assert_eq!(commit.as_deref(), Some("abc1234"));
2367                assert_eq!(blocked_on.as_deref(), Some("waiting for review"));
2368                assert!(clear_blocked);
2369            }
2370            other => panic!("expected task update command, got {other:?}"),
2371        }
2372    }
2373
2374    #[test]
2375    fn nudge_disable_parses() {
2376        let cli = Cli::parse_from(["batty", "nudge", "disable", "triage"]);
2377        match cli.command {
2378            Command::Nudge {
2379                command: NudgeCommand::Disable { name },
2380            } => assert_eq!(name, NudgeIntervention::Triage),
2381            other => panic!("expected nudge disable, got {other:?}"),
2382        }
2383    }
2384
2385    #[test]
2386    fn nudge_enable_parses() {
2387        let cli = Cli::parse_from(["batty", "nudge", "enable", "replenish"]);
2388        match cli.command {
2389            Command::Nudge {
2390                command: NudgeCommand::Enable { name },
2391            } => assert_eq!(name, NudgeIntervention::Replenish),
2392            other => panic!("expected nudge enable, got {other:?}"),
2393        }
2394    }
2395
2396    #[test]
2397    fn nudge_status_parses() {
2398        let cli = Cli::parse_from(["batty", "nudge", "status"]);
2399        match cli.command {
2400            Command::Nudge {
2401                command: NudgeCommand::Status,
2402            } => {}
2403            other => panic!("expected nudge status, got {other:?}"),
2404        }
2405    }
2406
2407    #[test]
2408    fn nudge_disable_owned_task_parses() {
2409        let cli = Cli::parse_from(["batty", "nudge", "disable", "owned-task"]);
2410        match cli.command {
2411            Command::Nudge {
2412                command: NudgeCommand::Disable { name },
2413            } => assert_eq!(name, NudgeIntervention::OwnedTask),
2414            other => panic!("expected nudge disable owned-task, got {other:?}"),
2415        }
2416    }
2417
2418    #[test]
2419    fn nudge_rejects_unknown_intervention() {
2420        let result = Cli::try_parse_from(["batty", "nudge", "disable", "unknown"]);
2421        assert!(result.is_err());
2422    }
2423
2424    #[test]
2425    fn nudge_intervention_marker_names() {
2426        assert_eq!(NudgeIntervention::Replenish.marker_name(), "replenish");
2427        assert_eq!(NudgeIntervention::Triage.marker_name(), "triage");
2428        assert_eq!(NudgeIntervention::Review.marker_name(), "review");
2429        assert_eq!(NudgeIntervention::Dispatch.marker_name(), "dispatch");
2430        assert_eq!(NudgeIntervention::Utilization.marker_name(), "utilization");
2431        assert_eq!(NudgeIntervention::OwnedTask.marker_name(), "owned-task");
2432    }
2433
2434    #[test]
2435    fn parse_task_schedule_at() {
2436        let cli = Cli::parse_from([
2437            "batty",
2438            "task",
2439            "schedule",
2440            "50",
2441            "--at",
2442            "2026-03-25T09:00:00-04:00",
2443        ]);
2444        match cli.command {
2445            Command::Task {
2446                command:
2447                    TaskCommand::Schedule {
2448                        task_id,
2449                        at,
2450                        cron,
2451                        clear,
2452                    },
2453            } => {
2454                assert_eq!(task_id, 50);
2455                assert_eq!(at.as_deref(), Some("2026-03-25T09:00:00-04:00"));
2456                assert!(cron.is_none());
2457                assert!(!clear);
2458            }
2459            other => panic!("expected task schedule command, got {other:?}"),
2460        }
2461    }
2462
2463    #[test]
2464    fn parse_task_schedule_cron() {
2465        let cli = Cli::parse_from(["batty", "task", "schedule", "51", "--cron", "0 9 * * *"]);
2466        match cli.command {
2467            Command::Task {
2468                command:
2469                    TaskCommand::Schedule {
2470                        task_id,
2471                        at,
2472                        cron,
2473                        clear,
2474                    },
2475            } => {
2476                assert_eq!(task_id, 51);
2477                assert!(at.is_none());
2478                assert_eq!(cron.as_deref(), Some("0 9 * * *"));
2479                assert!(!clear);
2480            }
2481            other => panic!("expected task schedule command, got {other:?}"),
2482        }
2483    }
2484
2485    #[test]
2486    fn parse_task_schedule_clear() {
2487        let cli = Cli::parse_from(["batty", "task", "schedule", "52", "--clear"]);
2488        match cli.command {
2489            Command::Task {
2490                command:
2491                    TaskCommand::Schedule {
2492                        task_id,
2493                        at,
2494                        cron,
2495                        clear,
2496                    },
2497            } => {
2498                assert_eq!(task_id, 52);
2499                assert!(at.is_none());
2500                assert!(cron.is_none());
2501                assert!(clear);
2502            }
2503            other => panic!("expected task schedule command, got {other:?}"),
2504        }
2505    }
2506
2507    #[test]
2508    fn parse_task_schedule_both() {
2509        let cli = Cli::parse_from([
2510            "batty",
2511            "task",
2512            "schedule",
2513            "53",
2514            "--at",
2515            "2026-04-01T00:00:00Z",
2516            "--cron",
2517            "0 9 * * 1",
2518        ]);
2519        match cli.command {
2520            Command::Task {
2521                command:
2522                    TaskCommand::Schedule {
2523                        task_id,
2524                        at,
2525                        cron,
2526                        clear,
2527                    },
2528            } => {
2529                assert_eq!(task_id, 53);
2530                assert_eq!(at.as_deref(), Some("2026-04-01T00:00:00Z"));
2531                assert_eq!(cron.as_deref(), Some("0 9 * * 1"));
2532                assert!(!clear);
2533            }
2534            other => panic!("expected task schedule command, got {other:?}"),
2535        }
2536    }
2537
2538    #[test]
2539    fn review_approve_parses() {
2540        let cli = Cli::parse_from(["batty", "review", "42", "approve"]);
2541        match cli.command {
2542            Command::Review {
2543                task_id,
2544                disposition,
2545                feedback,
2546                reviewer,
2547            } => {
2548                assert_eq!(task_id, 42);
2549                assert_eq!(disposition, ReviewAction::Approve);
2550                assert!(feedback.is_none());
2551                assert_eq!(reviewer, "human");
2552            }
2553            other => panic!("expected review command, got {other:?}"),
2554        }
2555    }
2556
2557    #[test]
2558    fn review_request_changes_with_feedback_parses() {
2559        let cli = Cli::parse_from([
2560            "batty",
2561            "review",
2562            "99",
2563            "request-changes",
2564            "fix the error handling",
2565        ]);
2566        match cli.command {
2567            Command::Review {
2568                task_id,
2569                disposition,
2570                feedback,
2571                reviewer,
2572            } => {
2573                assert_eq!(task_id, 99);
2574                assert_eq!(disposition, ReviewAction::RequestChanges);
2575                assert_eq!(feedback.as_deref(), Some("fix the error handling"));
2576                assert_eq!(reviewer, "human");
2577            }
2578            other => panic!("expected review command, got {other:?}"),
2579        }
2580    }
2581
2582    #[test]
2583    fn review_reject_with_reviewer_flag_parses() {
2584        let cli = Cli::parse_from([
2585            "batty",
2586            "review",
2587            "7",
2588            "reject",
2589            "does not meet requirements",
2590            "--reviewer",
2591            "manager-1",
2592        ]);
2593        match cli.command {
2594            Command::Review {
2595                task_id,
2596                disposition,
2597                feedback,
2598                reviewer,
2599            } => {
2600                assert_eq!(task_id, 7);
2601                assert_eq!(disposition, ReviewAction::Reject);
2602                assert_eq!(feedback.as_deref(), Some("does not meet requirements"));
2603                assert_eq!(reviewer, "manager-1");
2604            }
2605            other => panic!("expected review command, got {other:?}"),
2606        }
2607    }
2608
2609    #[test]
2610    fn review_rejects_invalid_disposition() {
2611        let result = Cli::try_parse_from(["batty", "review", "42", "maybe"]);
2612        assert!(result.is_err());
2613    }
2614
2615    // --- send: missing required args ---
2616
2617    #[test]
2618    fn send_rejects_missing_role() {
2619        let result = Cli::try_parse_from(["batty", "send"]);
2620        assert!(result.is_err());
2621    }
2622
2623    #[test]
2624    fn send_rejects_missing_message() {
2625        let result = Cli::try_parse_from(["batty", "send", "architect"]);
2626        assert!(result.is_err());
2627    }
2628
2629    // --- assign: missing required args ---
2630
2631    #[test]
2632    fn assign_rejects_missing_engineer() {
2633        let result = Cli::try_parse_from(["batty", "assign"]);
2634        assert!(result.is_err());
2635    }
2636
2637    #[test]
2638    fn assign_rejects_missing_task() {
2639        let result = Cli::try_parse_from(["batty", "assign", "eng-1-1"]);
2640        assert!(result.is_err());
2641    }
2642
2643    // --- review: missing required args ---
2644
2645    #[test]
2646    fn review_rejects_missing_task_id() {
2647        let result = Cli::try_parse_from(["batty", "review"]);
2648        assert!(result.is_err());
2649    }
2650
2651    #[test]
2652    fn review_rejects_missing_disposition() {
2653        let result = Cli::try_parse_from(["batty", "review", "42"]);
2654        assert!(result.is_err());
2655    }
2656
2657    // --- merge: missing required args ---
2658
2659    #[test]
2660    fn merge_rejects_missing_engineer() {
2661        let result = Cli::try_parse_from(["batty", "merge"]);
2662        assert!(result.is_err());
2663    }
2664
2665    // --- read/ack: missing required args ---
2666
2667    #[test]
2668    fn read_rejects_missing_member() {
2669        let result = Cli::try_parse_from(["batty", "read"]);
2670        assert!(result.is_err());
2671    }
2672
2673    #[test]
2674    fn read_rejects_missing_id() {
2675        let result = Cli::try_parse_from(["batty", "read", "architect"]);
2676        assert!(result.is_err());
2677    }
2678
2679    #[test]
2680    fn ack_rejects_missing_args() {
2681        let result = Cli::try_parse_from(["batty", "ack"]);
2682        assert!(result.is_err());
2683    }
2684
2685    // --- telemetry subcommands ---
2686
2687    #[test]
2688    fn telemetry_summary_parses() {
2689        let cli = Cli::parse_from(["batty", "telemetry", "summary"]);
2690        match cli.command {
2691            Command::Telemetry {
2692                command: TelemetryCommand::Summary,
2693            } => {}
2694            other => panic!("expected telemetry summary, got {other:?}"),
2695        }
2696    }
2697
2698    #[test]
2699    fn telemetry_agents_parses() {
2700        let cli = Cli::parse_from(["batty", "telemetry", "agents"]);
2701        match cli.command {
2702            Command::Telemetry {
2703                command: TelemetryCommand::Agents,
2704            } => {}
2705            other => panic!("expected telemetry agents, got {other:?}"),
2706        }
2707    }
2708
2709    #[test]
2710    fn telemetry_tasks_parses() {
2711        let cli = Cli::parse_from(["batty", "telemetry", "tasks"]);
2712        match cli.command {
2713            Command::Telemetry {
2714                command: TelemetryCommand::Tasks,
2715            } => {}
2716            other => panic!("expected telemetry tasks, got {other:?}"),
2717        }
2718    }
2719
2720    #[test]
2721    fn telemetry_reviews_parses() {
2722        let cli = Cli::parse_from(["batty", "telemetry", "reviews"]);
2723        match cli.command {
2724            Command::Telemetry {
2725                command: TelemetryCommand::Reviews,
2726            } => {}
2727            other => panic!("expected telemetry reviews, got {other:?}"),
2728        }
2729    }
2730
2731    #[test]
2732    fn telemetry_events_default_limit() {
2733        let cli = Cli::parse_from(["batty", "telemetry", "events"]);
2734        match cli.command {
2735            Command::Telemetry {
2736                command: TelemetryCommand::Events { limit },
2737            } => assert_eq!(limit, 50),
2738            other => panic!("expected telemetry events, got {other:?}"),
2739        }
2740    }
2741
2742    #[test]
2743    fn telemetry_events_custom_limit() {
2744        let cli = Cli::parse_from(["batty", "telemetry", "events", "-n", "10"]);
2745        match cli.command {
2746            Command::Telemetry {
2747                command: TelemetryCommand::Events { limit },
2748            } => assert_eq!(limit, 10),
2749            other => panic!("expected telemetry events with limit, got {other:?}"),
2750        }
2751    }
2752
2753    #[test]
2754    fn telemetry_rejects_missing_subcommand() {
2755        let result = Cli::try_parse_from(["batty", "telemetry"]);
2756        assert!(result.is_err());
2757    }
2758
2759    // --- grafana ---
2760
2761    #[test]
2762    fn grafana_setup_parses() {
2763        let cli = Cli::parse_from(["batty", "grafana", "setup"]);
2764        assert!(matches!(
2765            cli.command,
2766            Command::Grafana {
2767                command: GrafanaCommand::Setup
2768            }
2769        ));
2770    }
2771
2772    #[test]
2773    fn grafana_status_parses() {
2774        let cli = Cli::parse_from(["batty", "grafana", "status"]);
2775        assert!(matches!(
2776            cli.command,
2777            Command::Grafana {
2778                command: GrafanaCommand::Status
2779            }
2780        ));
2781    }
2782
2783    #[test]
2784    fn grafana_open_parses() {
2785        let cli = Cli::parse_from(["batty", "grafana", "open"]);
2786        assert!(matches!(
2787            cli.command,
2788            Command::Grafana {
2789                command: GrafanaCommand::Open
2790            }
2791        ));
2792    }
2793
2794    #[test]
2795    fn grafana_rejects_missing_subcommand() {
2796        let result = Cli::try_parse_from(["batty", "grafana"]);
2797        assert!(result.is_err());
2798    }
2799
2800    // --- task auto-merge ---
2801
2802    #[test]
2803    fn task_auto_merge_enable_parses() {
2804        let cli = Cli::parse_from(["batty", "task", "auto-merge", "30", "enable"]);
2805        match cli.command {
2806            Command::Task {
2807                command: TaskCommand::AutoMerge { task_id, action },
2808            } => {
2809                assert_eq!(task_id, 30);
2810                assert_eq!(action, AutoMergeAction::Enable);
2811            }
2812            other => panic!("expected task auto-merge enable, got {other:?}"),
2813        }
2814    }
2815
2816    #[test]
2817    fn task_auto_merge_disable_parses() {
2818        let cli = Cli::parse_from(["batty", "task", "auto-merge", "31", "disable"]);
2819        match cli.command {
2820            Command::Task {
2821                command: TaskCommand::AutoMerge { task_id, action },
2822            } => {
2823                assert_eq!(task_id, 31);
2824                assert_eq!(action, AutoMergeAction::Disable);
2825            }
2826            other => panic!("expected task auto-merge disable, got {other:?}"),
2827        }
2828    }
2829
2830    #[test]
2831    fn task_auto_merge_rejects_invalid_action() {
2832        let result = Cli::try_parse_from(["batty", "task", "auto-merge", "30", "toggle"]);
2833        assert!(result.is_err());
2834    }
2835
2836    // --- task assign with partial owners ---
2837
2838    #[test]
2839    fn task_assign_execution_owner_only() {
2840        let cli = Cli::parse_from([
2841            "batty",
2842            "task",
2843            "assign",
2844            "10",
2845            "--execution-owner",
2846            "eng-1-3",
2847        ]);
2848        match cli.command {
2849            Command::Task {
2850                command:
2851                    TaskCommand::Assign {
2852                        task_id,
2853                        execution_owner,
2854                        review_owner,
2855                    },
2856            } => {
2857                assert_eq!(task_id, 10);
2858                assert_eq!(execution_owner.as_deref(), Some("eng-1-3"));
2859                assert!(review_owner.is_none());
2860            }
2861            other => panic!("expected task assign command, got {other:?}"),
2862        }
2863    }
2864
2865    // --- task rejects missing subcommand ---
2866
2867    #[test]
2868    fn task_rejects_missing_subcommand() {
2869        let result = Cli::try_parse_from(["batty", "task"]);
2870        assert!(result.is_err());
2871    }
2872
2873    // --- doctor: --yes requires --fix ---
2874
2875    #[test]
2876    fn doctor_rejects_yes_without_fix() {
2877        let result = Cli::try_parse_from(["batty", "doctor", "--yes"]);
2878        assert!(result.is_err());
2879    }
2880
2881    // --- daemon hidden subcommand ---
2882
2883    #[test]
2884    fn daemon_subcommand_parses() {
2885        let cli = Cli::parse_from(["batty", "daemon", "--project-root", "/tmp/project"]);
2886        match cli.command {
2887            Command::Daemon {
2888                project_root,
2889                resume,
2890            } => {
2891                assert_eq!(project_root, "/tmp/project");
2892                assert!(!resume);
2893            }
2894            other => panic!("expected daemon command, got {other:?}"),
2895        }
2896    }
2897
2898    #[test]
2899    fn daemon_subcommand_parses_resume_flag() {
2900        let cli = Cli::parse_from([
2901            "batty",
2902            "daemon",
2903            "--project-root",
2904            "/tmp/project",
2905            "--resume",
2906        ]);
2907        match cli.command {
2908            Command::Daemon {
2909                project_root,
2910                resume,
2911            } => {
2912                assert_eq!(project_root, "/tmp/project");
2913                assert!(resume);
2914            }
2915            other => panic!("expected daemon command with resume, got {other:?}"),
2916        }
2917    }
2918
2919    #[test]
2920    fn watchdog_subcommand_parses() {
2921        let cli = Cli::parse_from(["batty", "watchdog", "--project-root", "/tmp/project"]);
2922        match cli.command {
2923            Command::Watchdog {
2924                project_root,
2925                resume,
2926            } => {
2927                assert_eq!(project_root, "/tmp/project");
2928                assert!(!resume);
2929            }
2930            other => panic!("expected watchdog command, got {other:?}"),
2931        }
2932    }
2933
2934    #[test]
2935    fn watchdog_subcommand_parses_resume_flag() {
2936        let cli = Cli::parse_from([
2937            "batty",
2938            "watchdog",
2939            "--project-root",
2940            "/tmp/project",
2941            "--resume",
2942        ]);
2943        match cli.command {
2944            Command::Watchdog {
2945                project_root,
2946                resume,
2947            } => {
2948                assert_eq!(project_root, "/tmp/project");
2949                assert!(resume);
2950            }
2951            other => panic!("expected watchdog command with resume, got {other:?}"),
2952        }
2953    }
2954
2955    // --- completions: all shell variants ---
2956
2957    #[test]
2958    fn completions_all_shells_parse() {
2959        for (arg, expected) in [
2960            ("bash", CompletionShell::Bash),
2961            ("zsh", CompletionShell::Zsh),
2962            ("fish", CompletionShell::Fish),
2963        ] {
2964            let cli = Cli::parse_from(["batty", "completions", arg]);
2965            match cli.command {
2966                Command::Completions { shell } => assert_eq!(shell, expected, "shell arg={arg}"),
2967                other => panic!("expected completions command for {arg}, got {other:?}"),
2968            }
2969        }
2970    }
2971
2972    #[test]
2973    fn completions_rejects_unknown_shell() {
2974        let result = Cli::try_parse_from(["batty", "completions", "powershell"]);
2975        assert!(result.is_err());
2976    }
2977
2978    // --- init: all template variants ---
2979
2980    #[test]
2981    fn init_all_template_variants() {
2982        for (arg, expected) in [
2983            ("solo", InitTemplate::Solo),
2984            ("pair", InitTemplate::Pair),
2985            ("simple", InitTemplate::Simple),
2986            ("squad", InitTemplate::Squad),
2987            ("large", InitTemplate::Large),
2988            ("research", InitTemplate::Research),
2989            ("software", InitTemplate::Software),
2990            ("cleanroom", InitTemplate::Cleanroom),
2991            ("batty", InitTemplate::Batty),
2992        ] {
2993            let cli = Cli::parse_from(["batty", "init", "--template", arg]);
2994            match cli.command {
2995                Command::Init { template, from, .. } => {
2996                    assert_eq!(template, Some(expected), "template arg={arg}");
2997                    assert!(from.is_none());
2998                }
2999                other => panic!("expected init command for template {arg}, got {other:?}"),
3000            }
3001        }
3002    }
3003
3004    // --- task review with feedback ---
3005
3006    #[test]
3007    fn task_review_with_feedback_parses() {
3008        let cli = Cli::parse_from([
3009            "batty",
3010            "task",
3011            "review",
3012            "15",
3013            "--disposition",
3014            "changes_requested",
3015            "--feedback",
3016            "please fix tests",
3017        ]);
3018        match cli.command {
3019            Command::Task {
3020                command:
3021                    TaskCommand::Review {
3022                        task_id,
3023                        disposition,
3024                        feedback,
3025                    },
3026            } => {
3027                assert_eq!(task_id, 15);
3028                assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
3029                assert_eq!(feedback.as_deref(), Some("please fix tests"));
3030            }
3031            other => panic!("expected task review command, got {other:?}"),
3032        }
3033    }
3034
3035    // --- task transition: all states ---
3036
3037    #[test]
3038    fn task_transition_all_states() {
3039        for (arg, expected) in [
3040            ("backlog", TaskStateArg::Backlog),
3041            ("todo", TaskStateArg::Todo),
3042            ("in-progress", TaskStateArg::InProgress),
3043            ("review", TaskStateArg::Review),
3044            ("blocked", TaskStateArg::Blocked),
3045            ("done", TaskStateArg::Done),
3046            ("archived", TaskStateArg::Archived),
3047        ] {
3048            let cli = Cli::parse_from(["batty", "task", "transition", "1", arg]);
3049            match cli.command {
3050                Command::Task {
3051                    command:
3052                        TaskCommand::Transition {
3053                            task_id,
3054                            target_state,
3055                        },
3056                } => {
3057                    assert_eq!(task_id, 1);
3058                    assert_eq!(target_state, expected, "state arg={arg}");
3059                }
3060                other => panic!("expected task transition for {arg}, got {other:?}"),
3061            }
3062        }
3063    }
3064
3065    #[test]
3066    fn task_transition_rejects_invalid_state() {
3067        let result = Cli::try_parse_from(["batty", "task", "transition", "1", "cancelled"]);
3068        assert!(result.is_err());
3069    }
3070
3071    // --- unknown subcommand ---
3072
3073    #[test]
3074    fn rejects_unknown_subcommand() {
3075        let result = Cli::try_parse_from(["batty", "foobar"]);
3076        assert!(result.is_err());
3077    }
3078
3079    // --- no args ---
3080
3081    #[test]
3082    fn rejects_no_subcommand() {
3083        let result = Cli::try_parse_from(["batty"]);
3084        assert!(result.is_err());
3085    }
3086
3087    // --- inbox purge requires role or all-roles ---
3088
3089    #[test]
3090    fn inbox_purge_rejects_missing_role_and_all_roles() {
3091        let result = Cli::try_parse_from(["batty", "inbox", "purge", "--all"]);
3092        assert!(result.is_err());
3093    }
3094
3095    // --- nudge: all intervention variants ---
3096
3097    #[test]
3098    fn nudge_enable_all_interventions() {
3099        for (arg, expected) in [
3100            ("replenish", NudgeIntervention::Replenish),
3101            ("triage", NudgeIntervention::Triage),
3102            ("review", NudgeIntervention::Review),
3103            ("dispatch", NudgeIntervention::Dispatch),
3104            ("utilization", NudgeIntervention::Utilization),
3105            ("owned-task", NudgeIntervention::OwnedTask),
3106        ] {
3107            let cli = Cli::parse_from(["batty", "nudge", "enable", arg]);
3108            match cli.command {
3109                Command::Nudge {
3110                    command: NudgeCommand::Enable { name },
3111                } => assert_eq!(name, expected, "nudge enable arg={arg}"),
3112                other => panic!("expected nudge enable for {arg}, got {other:?}"),
3113            }
3114        }
3115    }
3116
3117    // --- config: default (no --json) ---
3118
3119    #[test]
3120    fn config_subcommand_defaults_no_json() {
3121        let cli = Cli::parse_from(["batty", "config"]);
3122        match cli.command {
3123            Command::Config { json } => assert!(!json),
3124            other => panic!("expected config command, got {other:?}"),
3125        }
3126    }
3127
3128    // --- completion generation tests ---
3129
3130    /// Helper: generate completion script for a shell into a String.
3131    fn generate_completions(shell: clap_complete::Shell) -> String {
3132        use clap::CommandFactory;
3133        let mut buf = Vec::new();
3134        clap_complete::generate(shell, &mut Cli::command(), "batty", &mut buf);
3135        String::from_utf8(buf).expect("completions should be valid UTF-8")
3136    }
3137
3138    #[test]
3139    fn completions_bash_generates() {
3140        let output = generate_completions(clap_complete::Shell::Bash);
3141        assert!(!output.is_empty(), "bash completions should not be empty");
3142        assert!(
3143            output.contains("_batty"),
3144            "bash completions should define _batty function"
3145        );
3146    }
3147
3148    #[test]
3149    fn completions_zsh_generates() {
3150        let output = generate_completions(clap_complete::Shell::Zsh);
3151        assert!(!output.is_empty(), "zsh completions should not be empty");
3152        assert!(
3153            output.contains("#compdef batty"),
3154            "zsh completions should start with #compdef"
3155        );
3156    }
3157
3158    #[test]
3159    fn completions_fish_generates() {
3160        let output = generate_completions(clap_complete::Shell::Fish);
3161        assert!(!output.is_empty(), "fish completions should not be empty");
3162        assert!(
3163            output.contains("complete -c batty"),
3164            "fish completions should contain complete -c batty"
3165        );
3166    }
3167
3168    #[test]
3169    fn completions_include_grafana_subcommands() {
3170        let output = generate_completions(clap_complete::Shell::Fish);
3171        // Top-level grafana command
3172        assert!(
3173            output.contains("grafana"),
3174            "completions should include grafana command"
3175        );
3176        // Grafana subcommands
3177        assert!(
3178            output.contains("setup"),
3179            "completions should include grafana setup"
3180        );
3181        assert!(
3182            output.contains("status"),
3183            "completions should include grafana status"
3184        );
3185        assert!(
3186            output.contains("open"),
3187            "completions should include grafana open"
3188        );
3189    }
3190
3191    #[test]
3192    fn completions_include_all_recent_commands() {
3193        let output = generate_completions(clap_complete::Shell::Fish);
3194        let expected_commands = [
3195            "task",
3196            "metrics",
3197            "grafana",
3198            "telemetry",
3199            "nudge",
3200            "load",
3201            "queue",
3202            "cost",
3203            "doctor",
3204            "pause",
3205            "resume",
3206        ];
3207        for cmd in &expected_commands {
3208            assert!(
3209                output.contains(cmd),
3210                "completions should include '{cmd}' command"
3211            );
3212        }
3213    }
3214}