use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser, Debug)]
#[command(
name = "batty",
about = "Hierarchical agent team system for software development",
version = concat!(env!("CARGO_PKG_VERSION"), "\nhttps://github.com/battysh/batty")
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
}
#[derive(Subcommand, Debug)]
pub enum Command {
#[command(alias = "install")]
Init {
#[arg(long, value_enum, conflicts_with = "from")]
template: Option<InitTemplate>,
#[arg(long, conflicts_with = "template")]
from: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
agent: Option<String>,
},
ExportTemplate {
name: String,
},
ExportRun,
Retro {
#[arg(long)]
events: Option<PathBuf>,
},
Start {
#[arg(long, default_value_t = false)]
attach: bool,
#[arg(short, long, default_value_t = false)]
quiet: bool,
},
Stop,
Attach,
Status {
#[arg(long, default_value_t = false)]
json: bool,
#[arg(long, default_value_t = false)]
detail: bool,
#[arg(long, default_value_t = false)]
health: bool,
},
#[command(name = "daemon-restart-if-stale")]
DaemonRestartIfStale {
#[arg(long, default_value_t = false)]
dry_run: bool,
},
Bench {
engineer: String,
#[arg(long)]
reason: Option<String>,
},
Unbench {
engineer: String,
},
#[command(name = "openclaw")]
OpenClaw {
#[command(subcommand)]
command: OpenClawCommand,
},
Send {
#[arg(long, hide = true)]
from: Option<String>,
role: String,
message: String,
},
Assign {
engineer: String,
task: String,
},
Validate {
#[arg(long, default_value_t = false)]
show_checks: bool,
},
Config {
#[arg(long, default_value_t = false)]
json: bool,
},
Board {
#[command(subcommand)]
command: Option<BoardCommand>,
},
#[command(args_conflicts_with_subcommands = true)]
Inbox {
#[command(subcommand)]
command: Option<InboxCommand>,
member: Option<String>,
#[arg(
short = 'n',
long = "limit",
default_value_t = 20,
conflicts_with = "all"
)]
limit: usize,
#[arg(long, default_value_t = false)]
all: bool,
#[arg(long, default_value_t = false)]
raw: bool,
},
Read {
member: String,
id: String,
},
Ack {
member: String,
id: String,
},
Merge {
engineer: String,
},
Task {
#[command(subcommand)]
command: TaskCommand,
},
#[command(hide = true)]
Activity {
#[command(subcommand)]
command: ActivityCommand,
},
Review {
task_id: u32,
#[arg(value_enum)]
disposition: ReviewAction,
feedback: Option<String>,
#[arg(long, default_value = "human")]
reviewer: String,
},
Completions {
#[arg(value_enum)]
shell: CompletionShell,
},
Nudge {
#[command(subcommand)]
command: NudgeCommand,
},
Pause,
Resume,
Grafana {
#[command(subcommand)]
command: GrafanaCommand,
},
Discord {
#[command(subcommand)]
command: Option<DiscordCommand>,
},
Telegram,
Project {
#[command(subcommand)]
command: ProjectCommand,
},
Load,
Parity {
#[arg(long, default_value_t = false, conflicts_with = "gaps")]
detail: bool,
#[arg(long, default_value_t = false)]
gaps: bool,
},
Verify,
Release {
#[arg(long)]
tag: Option<String>,
#[arg(long, default_value_t = false)]
readiness: bool,
},
Queue,
Dispatch {
#[arg(long, default_value_t = false)]
explain: bool,
#[arg(long)]
task: Option<u32>,
},
Cost,
Scale {
#[command(subcommand)]
command: ScaleCommand,
},
Reload,
Research {
#[command(subcommand)]
command: ResearchCommand,
},
Doctor {
#[arg(long, default_value_t = false)]
fix: bool,
#[arg(long, default_value_t = false, requires = "fix")]
yes: bool,
},
Worktree {
#[arg(long, default_value_t = false)]
health: bool,
},
Metrics,
StressTest {
#[arg(long, default_value_t = false)]
compact: bool,
#[arg(long, default_value_t = 8)]
duration_hours: u64,
#[arg(long, default_value_t = 1)]
seed: u64,
#[arg(long)]
json_out: Option<PathBuf>,
#[arg(long)]
markdown_out: Option<PathBuf>,
},
Telemetry {
#[command(subcommand)]
command: TelemetryCommand,
},
Chat {
#[arg(long, default_value = "generic")]
agent_type: String,
#[arg(long)]
cmd: Option<String>,
#[arg(long, default_value = ".")]
cwd: String,
#[arg(long, default_value_t = false)]
sdk_mode: bool,
},
#[command(hide = true)]
Shim {
#[arg(long)]
id: String,
#[arg(long)]
agent_type: String,
#[arg(long)]
cmd: String,
#[arg(long)]
cwd: String,
#[arg(long, default_value = "50")]
rows: u16,
#[arg(long, default_value = "220")]
cols: u16,
#[arg(long)]
pty_log_path: Option<String>,
#[arg(long, default_value_t = 5)]
graceful_shutdown_timeout_secs: u64,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
auto_commit_on_restart: bool,
#[arg(long, default_value_t = false)]
sdk_mode: bool,
},
#[command(hide = true)]
ConsolePane {
#[arg(long)]
project_root: String,
#[arg(long)]
member: String,
#[arg(long)]
events_log_path: String,
#[arg(long)]
pty_log_path: String,
},
#[command(hide = true)]
GrafanaWebhook {
#[arg(long)]
project_root: String,
#[arg(long, default_value_t = 8787)]
port: u16,
},
#[command(hide = true)]
Daemon {
#[arg(long)]
project_root: String,
#[arg(long)]
resume: bool,
},
#[command(hide = true)]
Watchdog {
#[arg(long)]
project_root: String,
#[arg(long)]
resume: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum TelemetryCommand {
Summary,
Agents,
Tasks,
Reviews,
Events {
#[arg(short = 'n', long = "limit", default_value_t = 50)]
limit: usize,
},
}
#[derive(Subcommand, Debug)]
pub enum GrafanaCommand {
Setup,
Status,
Open,
}
#[derive(Subcommand, Debug)]
pub enum DiscordCommand {
Setup,
Status,
}
#[derive(Subcommand, Debug)]
pub enum ProjectCommand {
Register {
#[arg(long = "project-id")]
project_id: String,
#[arg(long)]
name: String,
#[arg(long = "alias")]
aliases: Vec<String>,
#[arg(long = "project-root")]
project_root: PathBuf,
#[arg(long = "board-dir")]
board_dir: PathBuf,
#[arg(long = "team-name")]
team_name: String,
#[arg(long = "session-name")]
session_name: String,
#[arg(long)]
owner: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "channel-binding")]
channel_bindings: Vec<String>,
#[arg(long = "thread-binding")]
thread_bindings: Vec<String>,
#[arg(long, default_value_t = false)]
allow_openclaw_supervision: bool,
#[arg(long, default_value_t = false)]
allow_cross_project_routing: bool,
#[arg(long, default_value_t = false)]
allow_shared_service_routing: bool,
#[arg(long, default_value_t = false)]
archived: bool,
#[arg(long, default_value_t = false)]
json: bool,
},
Unregister {
project_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
List {
#[arg(long, default_value_t = false)]
json: bool,
},
Get {
project_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Start {
project_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Stop {
project_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Restart {
project_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Status {
project_id: String,
#[arg(long, default_value_t = false)]
json: bool,
},
SetActive {
project_id: String,
#[arg(long)]
channel: Option<String>,
#[arg(long)]
binding: Option<String>,
#[arg(long = "thread-binding")]
thread_binding: Option<String>,
#[arg(long, default_value_t = false)]
json: bool,
},
Resolve {
message: String,
#[arg(long)]
channel: Option<String>,
#[arg(long)]
binding: Option<String>,
#[arg(long = "thread-binding")]
thread_binding: Option<String>,
#[arg(long, default_value_t = false)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum InboxCommand {
Purge {
#[arg(required_unless_present = "all_roles")]
role: Option<String>,
#[arg(long, default_value_t = false)]
all_roles: bool,
#[arg(long, conflicts_with_all = ["all", "older_than"])]
before: Option<u64>,
#[arg(long, conflicts_with_all = ["all", "before"])]
older_than: Option<String>,
#[arg(long, default_value_t = false, conflicts_with_all = ["before", "older_than"])]
all: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum BoardCommand {
List {
#[arg(long)]
status: Option<String>,
},
Summary,
Deps {
#[arg(long, value_enum, default_value_t = DepsFormatArg::Tree)]
format: DepsFormatArg,
},
Archive {
#[arg(long, default_value = "0s")]
older_than: String,
#[arg(long)]
dry_run: bool,
},
Health,
}
#[derive(Subcommand, Debug)]
pub enum ActivityCommand {
AnnotateStatus {
task_id: u32,
#[arg(long)]
source: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum DepsFormatArg {
Tree,
Flat,
Dot,
}
#[derive(Subcommand, Debug)]
pub enum TaskCommand {
Transition {
task_id: u32,
#[arg(value_enum)]
target_state: TaskStateArg,
},
Assign {
task_id: u32,
#[arg(long = "execution-owner")]
execution_owner: Option<String>,
#[arg(long = "review-owner")]
review_owner: Option<String>,
},
Review {
task_id: u32,
#[arg(long, value_enum)]
disposition: ReviewDispositionArg,
#[arg(long)]
feedback: Option<String>,
},
Update {
task_id: u32,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long = "blocked-on")]
blocked_on: Option<String>,
#[arg(long = "clear-blocked", default_value_t = false)]
clear_blocked: bool,
},
#[command(name = "auto-merge")]
AutoMerge {
task_id: u32,
#[arg(value_enum)]
action: AutoMergeAction,
},
Schedule {
task_id: u32,
#[arg(long = "at")]
at: Option<String>,
#[arg(long = "cron")]
cron: Option<String>,
#[arg(long, default_value_t = false)]
clear: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum InitTemplate {
Solo,
Pair,
Simple,
Squad,
Large,
Research,
Software,
Cleanroom,
Batty,
Python,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CompletionShell {
Bash,
Zsh,
Fish,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TaskStateArg {
Backlog,
Todo,
#[value(name = "in-progress")]
InProgress,
Review,
Blocked,
Done,
Archived,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReviewDispositionArg {
Approved,
#[value(name = "changes_requested")]
ChangesRequested,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReviewAction {
Approve,
#[value(name = "request-changes")]
RequestChanges,
Reject,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum AutoMergeAction {
Enable,
Disable,
}
#[derive(Subcommand, Debug)]
pub enum ScaleCommand {
Engineers {
count: u32,
},
AddManager {
name: String,
},
RemoveManager {
name: String,
},
Status,
}
#[derive(Subcommand, Debug)]
pub enum ResearchCommand {
Start {
hypothesis: String,
#[arg(long)]
evaluator: String,
#[arg(long, value_enum, default_value_t = ResearchFormatArg::Json)]
format: ResearchFormatArg,
#[arg(long, value_enum, default_value_t = ResearchKeepPolicyArg::PassOnly)]
keep_policy: ResearchKeepPolicyArg,
#[arg(long, default_value_t = 10)]
max_iterations: u32,
#[arg(long, default_value = ".")]
worktree: PathBuf,
},
Status,
Ledger,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ResearchFormatArg {
Json,
#[value(name = "exit-code")]
ExitCode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ResearchKeepPolicyArg {
#[value(name = "pass-only")]
PassOnly,
#[value(name = "score-improvement")]
ScoreImprovement,
#[value(name = "parity-improvement")]
ParityImprovement,
}
#[derive(Subcommand, Debug)]
pub enum NudgeCommand {
Disable {
#[arg(value_enum)]
name: NudgeIntervention,
},
Enable {
#[arg(value_enum)]
name: NudgeIntervention,
},
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum NudgeIntervention {
Replenish,
Triage,
Review,
Dispatch,
Utilization,
#[value(name = "owned-task")]
OwnedTask,
}
#[derive(Subcommand, Debug)]
pub enum OpenClawCommand {
Register {
#[arg(long, default_value_t = false)]
force: bool,
},
Status {
#[arg(long, default_value_t = false)]
json: bool,
},
Instruct {
role: String,
message: String,
},
Events {
#[arg(long = "project-id", conflicts_with = "all_projects")]
project_id: Option<String>,
#[arg(
long = "all-projects",
default_value_t = false,
conflicts_with = "project_id"
)]
all_projects: bool,
#[arg(long, default_value_t = false)]
json: bool,
#[arg(long = "topic", value_enum)]
topics: Vec<OpenClawEventTopicArg>,
#[arg(long = "role")]
roles: Vec<String>,
#[arg(long = "task-id")]
task_ids: Vec<String>,
#[arg(long = "event-type")]
event_types: Vec<String>,
#[arg(long = "session-name")]
session_names: Vec<String>,
#[arg(long = "since-ts")]
since_ts: Option<u64>,
#[arg(long)]
limit: Option<usize>,
#[arg(long, default_value_t = false)]
include_archived: bool,
},
#[command(name = "follow-up")]
FollowUp {
#[command(subcommand)]
command: OpenClawFollowUpCommand,
},
}
#[derive(Subcommand, Debug)]
pub enum OpenClawFollowUpCommand {
Run {
#[arg(long, default_value_t = false)]
json: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OpenClawEventTopicArg {
Completion,
Review,
Stall,
Merge,
Escalation,
#[value(name = "delivery-failure")]
DeliveryFailure,
Lifecycle,
}
impl NudgeIntervention {
#[allow(dead_code)]
pub fn marker_name(self) -> &'static str {
match self {
Self::Replenish => "replenish",
Self::Triage => "triage",
Self::Review => "review",
Self::Dispatch => "dispatch",
Self::Utilization => "utilization",
Self::OwnedTask => "owned-task",
}
}
#[allow(dead_code)]
pub const ALL: [NudgeIntervention; 6] = [
Self::Replenish,
Self::Triage,
Self::Review,
Self::Dispatch,
Self::Utilization,
Self::OwnedTask,
];
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn board_command_defaults_to_tui() {
let cli = Cli::parse_from(["batty", "board"]);
match cli.command {
Command::Board { command } => assert!(command.is_none()),
other => panic!("expected board command, got {other:?}"),
}
}
#[test]
fn board_list_subcommand_parses() {
let cli = Cli::parse_from(["batty", "board", "list"]);
match cli.command {
Command::Board {
command: Some(BoardCommand::List { status }),
} => assert_eq!(status, None),
other => panic!("expected board list command, got {other:?}"),
}
}
#[test]
fn board_list_subcommand_parses_status_filter() {
let cli = Cli::parse_from(["batty", "board", "list", "--status", "review"]);
match cli.command {
Command::Board {
command: Some(BoardCommand::List { status }),
} => assert_eq!(status.as_deref(), Some("review")),
other => panic!("expected board list command, got {other:?}"),
}
}
#[test]
fn board_summary_subcommand_parses() {
let cli = Cli::parse_from(["batty", "board", "summary"]);
match cli.command {
Command::Board {
command: Some(BoardCommand::Summary),
} => {}
other => panic!("expected board summary command, got {other:?}"),
}
}
#[test]
fn board_deps_subcommand_defaults_to_tree() {
let cli = Cli::parse_from(["batty", "board", "deps"]);
match cli.command {
Command::Board {
command: Some(BoardCommand::Deps { format }),
} => assert_eq!(format, DepsFormatArg::Tree),
other => panic!("expected board deps command, got {other:?}"),
}
}
#[test]
fn reload_subcommand_parses() {
let cli = Cli::parse_from(["batty", "reload"]);
match cli.command {
Command::Reload => {}
other => panic!("expected reload command, got {other:?}"),
}
}
#[test]
fn research_start_subcommand_parses() {
let cli = Cli::parse_from([
"batty",
"research",
"start",
"improve baseline",
"--evaluator",
"cargo test",
"--format",
"exit-code",
"--keep-policy",
"parity-improvement",
"--max-iterations",
"3",
"--worktree",
"tmp/research",
]);
match cli.command {
Command::Research {
command:
ResearchCommand::Start {
hypothesis,
evaluator,
format,
keep_policy,
max_iterations,
worktree,
},
} => {
assert_eq!(hypothesis, "improve baseline");
assert_eq!(evaluator, "cargo test");
assert_eq!(format, ResearchFormatArg::ExitCode);
assert_eq!(keep_policy, ResearchKeepPolicyArg::ParityImprovement);
assert_eq!(max_iterations, 3);
assert_eq!(worktree, PathBuf::from("tmp/research"));
}
other => panic!("expected research start command, got {other:?}"),
}
}
#[test]
fn research_status_subcommand_parses() {
let cli = Cli::parse_from(["batty", "research", "status"]);
match cli.command {
Command::Research {
command: ResearchCommand::Status,
} => {}
other => panic!("expected research status command, got {other:?}"),
}
}
#[test]
fn board_deps_subcommand_parses_format_flag() {
for (arg, expected) in [
("tree", DepsFormatArg::Tree),
("flat", DepsFormatArg::Flat),
("dot", DepsFormatArg::Dot),
] {
let cli = Cli::parse_from(["batty", "board", "deps", "--format", arg]);
match cli.command {
Command::Board {
command: Some(BoardCommand::Deps { format }),
} => assert_eq!(format, expected, "format arg={arg}"),
other => panic!("expected board deps command for {arg}, got {other:?}"),
}
}
}
#[test]
fn board_archive_subcommand_parses() {
let cli = Cli::parse_from(["batty", "board", "archive"]);
match cli.command {
Command::Board {
command:
Some(BoardCommand::Archive {
older_than,
dry_run,
}),
} => {
assert_eq!(older_than, "0s");
assert!(!dry_run);
}
other => panic!("expected board archive command, got {other:?}"),
}
}
#[test]
fn board_archive_subcommand_parses_older_than() {
let cli = Cli::parse_from(["batty", "board", "archive", "--older-than", "7d"]);
match cli.command {
Command::Board {
command:
Some(BoardCommand::Archive {
older_than,
dry_run,
}),
} => {
assert_eq!(older_than, "7d");
assert!(!dry_run);
}
other => panic!("expected board archive command with older_than, got {other:?}"),
}
}
#[test]
fn board_archive_subcommand_parses_dry_run() {
let cli = Cli::parse_from(["batty", "board", "archive", "--dry-run"]);
match cli.command {
Command::Board {
command:
Some(BoardCommand::Archive {
older_than,
dry_run,
}),
} => {
assert_eq!(older_than, "0s");
assert!(dry_run);
}
other => panic!("expected board archive command with dry_run, got {other:?}"),
}
}
#[test]
fn board_health_subcommand_parses() {
let cli = Cli::parse_from(["batty", "board", "health"]);
match cli.command {
Command::Board {
command: Some(BoardCommand::Health),
} => {}
other => panic!("expected board health command, got {other:?}"),
}
}
#[test]
fn init_subcommand_defaults_to_simple() {
let cli = Cli::parse_from(["batty", "init"]);
match cli.command {
Command::Init {
template,
from,
agent,
..
} => {
assert_eq!(template, None);
assert_eq!(from, None);
assert_eq!(agent, None);
}
other => panic!("expected init command, got {other:?}"),
}
}
#[test]
fn init_subcommand_accepts_large_template() {
let cli = Cli::parse_from(["batty", "init", "--template", "large"]);
match cli.command {
Command::Init { template, from, .. } => {
assert_eq!(template, Some(InitTemplate::Large));
assert_eq!(from, None);
}
other => panic!("expected init command, got {other:?}"),
}
}
#[test]
fn init_subcommand_accepts_from_template_name() {
let cli = Cli::parse_from(["batty", "init", "--from", "custom-team"]);
match cli.command {
Command::Init { template, from, .. } => {
assert_eq!(template, None);
assert_eq!(from.as_deref(), Some("custom-team"));
}
other => panic!("expected init command, got {other:?}"),
}
}
#[test]
fn init_subcommand_rejects_from_with_template() {
let result = Cli::try_parse_from(["batty", "init", "--template", "large", "--from", "x"]);
assert!(result.is_err());
}
#[test]
fn init_agent_flag_parses() {
let cli = Cli::parse_from(["batty", "init", "--agent", "codex"]);
match cli.command {
Command::Init { agent, .. } => {
assert_eq!(agent.as_deref(), Some("codex"));
}
other => panic!("expected init command, got {other:?}"),
}
}
#[test]
fn install_alias_maps_to_init() {
let cli = Cli::parse_from(["batty", "install"]);
match cli.command {
Command::Init {
template,
from,
agent,
..
} => {
assert_eq!(template, None);
assert_eq!(from, None);
assert_eq!(agent, None);
}
other => panic!("expected init command via install alias, got {other:?}"),
}
}
#[test]
fn install_alias_with_agent_flag() {
let cli = Cli::parse_from(["batty", "install", "--agent", "kiro"]);
match cli.command {
Command::Init { agent, .. } => {
assert_eq!(agent.as_deref(), Some("kiro"));
}
other => panic!("expected init command via install alias, got {other:?}"),
}
}
#[test]
fn export_template_subcommand_parses() {
let cli = Cli::parse_from(["batty", "export-template", "myteam"]);
match cli.command {
Command::ExportTemplate { name } => assert_eq!(name, "myteam"),
other => panic!("expected export-template command, got {other:?}"),
}
}
#[test]
fn export_run_subcommand_parses() {
let cli = Cli::parse_from(["batty", "export-run"]);
match cli.command {
Command::ExportRun => {}
other => panic!("expected export-run command, got {other:?}"),
}
}
#[test]
fn retro_subcommand_parses() {
let cli = Cli::parse_from(["batty", "retro"]);
match cli.command {
Command::Retro { events } => assert!(events.is_none()),
other => panic!("expected retro command, got {other:?}"),
}
}
#[test]
fn retro_subcommand_parses_with_events_path() {
let cli = Cli::parse_from(["batty", "retro", "--events", "/tmp/events.jsonl"]);
match cli.command {
Command::Retro { events } => {
assert_eq!(events, Some(PathBuf::from("/tmp/events.jsonl")));
}
other => panic!("expected retro command, got {other:?}"),
}
}
#[test]
fn start_subcommand_defaults() {
let cli = Cli::parse_from(["batty", "start"]);
match cli.command {
Command::Start { attach, quiet } => {
assert!(!attach);
assert!(!quiet);
}
other => panic!("expected start command, got {other:?}"),
}
}
#[test]
fn start_subcommand_with_attach() {
let cli = Cli::parse_from(["batty", "start", "--attach"]);
match cli.command {
Command::Start { attach, quiet } => {
assert!(attach);
assert!(!quiet);
}
other => panic!("expected start command, got {other:?}"),
}
}
#[test]
fn start_subcommand_with_quiet() {
let cli = Cli::parse_from(["batty", "start", "-q"]);
match cli.command {
Command::Start { attach, quiet } => {
assert!(!attach);
assert!(quiet);
}
other => panic!("expected start command, got {other:?}"),
}
}
#[test]
fn stop_subcommand_parses() {
let cli = Cli::parse_from(["batty", "stop"]);
assert!(matches!(cli.command, Command::Stop));
}
#[test]
fn attach_subcommand_parses() {
let cli = Cli::parse_from(["batty", "attach"]);
assert!(matches!(cli.command, Command::Attach));
}
#[test]
fn status_subcommand_defaults() {
let cli = Cli::parse_from(["batty", "status"]);
match cli.command {
Command::Status {
json,
detail,
health,
} => {
assert!(!json);
assert!(!detail);
assert!(!health);
}
other => panic!("expected status command, got {other:?}"),
}
}
#[test]
fn status_subcommand_json_flag() {
let cli = Cli::parse_from(["batty", "status", "--json"]);
match cli.command {
Command::Status {
json,
detail,
health,
} => {
assert!(json);
assert!(!detail);
assert!(!health);
}
other => panic!("expected status command, got {other:?}"),
}
}
#[test]
fn status_subcommand_detail_flag() {
let cli = Cli::parse_from(["batty", "status", "--detail"]);
match cli.command {
Command::Status {
json,
detail,
health,
} => {
assert!(!json);
assert!(detail);
assert!(!health);
}
other => panic!("expected status command, got {other:?}"),
}
}
#[test]
fn status_subcommand_health_flag() {
let cli = Cli::parse_from(["batty", "status", "--health"]);
match cli.command {
Command::Status {
json,
detail,
health,
} => {
assert!(!json);
assert!(!detail);
assert!(health);
}
other => panic!("expected status command, got {other:?}"),
}
}
#[test]
fn openclaw_register_subcommand_parses() {
let cli = Cli::parse_from(["batty", "openclaw", "register", "--force"]);
match cli.command {
Command::OpenClaw { command } => match command {
OpenClawCommand::Register { force } => assert!(force),
other => panic!("expected openclaw register command, got {other:?}"),
},
other => panic!("expected openclaw command, got {other:?}"),
}
}
#[test]
fn openclaw_status_subcommand_parses_json_flag() {
let cli = Cli::parse_from(["batty", "openclaw", "status", "--json"]);
match cli.command {
Command::OpenClaw { command } => match command {
OpenClawCommand::Status { json } => assert!(json),
other => panic!("expected openclaw status command, got {other:?}"),
},
other => panic!("expected openclaw command, got {other:?}"),
}
}
#[test]
fn openclaw_follow_up_run_subcommand_parses() {
let cli = Cli::parse_from(["batty", "openclaw", "follow-up", "run"]);
match cli.command {
Command::OpenClaw { command } => match command {
OpenClawCommand::FollowUp { command } => match command {
OpenClawFollowUpCommand::Run { json } => assert!(!json),
},
other => panic!("expected openclaw follow-up command, got {other:?}"),
},
other => panic!("expected openclaw command, got {other:?}"),
}
}
#[test]
fn openclaw_events_subcommand_parses_filters() {
let cli = Cli::parse_from([
"batty",
"openclaw",
"events",
"--project-id",
"fixture-degraded",
"--json",
"--topic",
"escalation",
"--topic",
"completion",
"--role",
"eng-1-1",
"--task-id",
"449",
"--event-type",
"task.escalated",
"--session-name",
"batty-fixture-team-degraded",
"--since-ts",
"1712402000",
"--limit",
"5",
"--include-archived",
]);
match cli.command {
Command::OpenClaw { command } => match command {
OpenClawCommand::Events {
project_id,
all_projects,
json,
topics,
roles,
task_ids,
event_types,
session_names,
since_ts,
limit,
include_archived,
} => {
assert_eq!(project_id.as_deref(), Some("fixture-degraded"));
assert!(!all_projects);
assert!(json);
assert_eq!(
topics,
vec![
OpenClawEventTopicArg::Escalation,
OpenClawEventTopicArg::Completion,
]
);
assert_eq!(roles, vec!["eng-1-1"]);
assert_eq!(task_ids, vec!["449"]);
assert_eq!(event_types, vec!["task.escalated"]);
assert_eq!(session_names, vec!["batty-fixture-team-degraded"]);
assert_eq!(since_ts, Some(1_712_402_000));
assert_eq!(limit, Some(5));
assert!(include_archived);
}
other => panic!("expected openclaw events command, got {other:?}"),
},
other => panic!("expected openclaw command, got {other:?}"),
}
}
#[test]
fn send_subcommand_parses_role_and_message() {
let cli = Cli::parse_from(["batty", "send", "architect", "hello world"]);
match cli.command {
Command::Send {
from,
role,
message,
} => {
assert!(from.is_none());
assert_eq!(role, "architect");
assert_eq!(message, "hello world");
}
other => panic!("expected send command, got {other:?}"),
}
}
#[test]
fn assign_subcommand_parses_engineer_and_task() {
let cli = Cli::parse_from(["batty", "assign", "eng-1-1", "fix auth bug"]);
match cli.command {
Command::Assign { engineer, task } => {
assert_eq!(engineer, "eng-1-1");
assert_eq!(task, "fix auth bug");
}
other => panic!("expected assign command, got {other:?}"),
}
}
#[test]
fn bench_subcommand_parses_reason() {
let cli = Cli::parse_from(["batty", "bench", "eng-1-1", "--reason", "session end"]);
match cli.command {
Command::Bench { engineer, reason } => {
assert_eq!(engineer, "eng-1-1");
assert_eq!(reason.as_deref(), Some("session end"));
}
other => panic!("expected bench command, got {other:?}"),
}
}
#[test]
fn unbench_subcommand_parses_engineer() {
let cli = Cli::parse_from(["batty", "unbench", "eng-1-1"]);
match cli.command {
Command::Unbench { engineer } => assert_eq!(engineer, "eng-1-1"),
other => panic!("expected unbench command, got {other:?}"),
}
}
#[test]
fn validate_subcommand_parses() {
let cli = Cli::parse_from(["batty", "validate"]);
match cli.command {
Command::Validate { show_checks } => assert!(!show_checks),
other => panic!("expected validate command, got {other:?}"),
}
}
#[test]
fn validate_subcommand_show_checks_flag() {
let cli = Cli::parse_from(["batty", "validate", "--show-checks"]);
match cli.command {
Command::Validate { show_checks } => assert!(show_checks),
other => panic!("expected validate command with show_checks, got {other:?}"),
}
}
#[test]
fn config_subcommand_json_flag() {
let cli = Cli::parse_from(["batty", "config", "--json"]);
match cli.command {
Command::Config { json } => assert!(json),
other => panic!("expected config command, got {other:?}"),
}
}
#[test]
fn merge_subcommand_parses_engineer() {
let cli = Cli::parse_from(["batty", "merge", "eng-1-1"]);
match cli.command {
Command::Merge { engineer } => assert_eq!(engineer, "eng-1-1"),
other => panic!("expected merge command, got {other:?}"),
}
}
#[test]
fn completions_subcommand_parses_shell() {
let cli = Cli::parse_from(["batty", "completions", "zsh"]);
match cli.command {
Command::Completions { shell } => assert_eq!(shell, CompletionShell::Zsh),
other => panic!("expected completions command, got {other:?}"),
}
}
#[test]
fn inbox_subcommand_parses_defaults() {
let cli = Cli::parse_from(["batty", "inbox", "architect"]);
match cli.command {
Command::Inbox {
command,
member,
limit,
all,
raw,
} => {
assert!(command.is_none());
assert_eq!(member.as_deref(), Some("architect"));
assert_eq!(limit, 20);
assert!(!all);
assert!(!raw);
}
other => panic!("expected inbox command, got {other:?}"),
}
}
#[test]
fn inbox_subcommand_parses_limit_flag() {
let cli = Cli::parse_from(["batty", "inbox", "architect", "-n", "50"]);
match cli.command {
Command::Inbox {
command,
member,
limit,
all,
raw,
} => {
assert!(command.is_none());
assert_eq!(member.as_deref(), Some("architect"));
assert_eq!(limit, 50);
assert!(!all);
assert!(!raw);
}
other => panic!("expected inbox command, got {other:?}"),
}
}
#[test]
fn inbox_subcommand_parses_all_flag() {
let cli = Cli::parse_from(["batty", "inbox", "architect", "--all"]);
match cli.command {
Command::Inbox {
command,
member,
limit,
all,
raw,
} => {
assert!(command.is_none());
assert_eq!(member.as_deref(), Some("architect"));
assert_eq!(limit, 20);
assert!(all);
assert!(!raw);
}
other => panic!("expected inbox command, got {other:?}"),
}
}
#[test]
fn inbox_subcommand_parses_raw_flag() {
let cli = Cli::parse_from(["batty", "inbox", "architect", "--raw"]);
match cli.command {
Command::Inbox {
command,
member,
raw,
..
} => {
assert!(command.is_none());
assert_eq!(member.as_deref(), Some("architect"));
assert!(raw);
}
other => panic!("expected inbox command, got {other:?}"),
}
}
#[test]
fn inbox_purge_subcommand_parses_role_and_before() {
let cli = Cli::parse_from(["batty", "inbox", "purge", "architect", "--before", "123"]);
match cli.command {
Command::Inbox {
command:
Some(InboxCommand::Purge {
role,
all_roles,
before,
older_than,
all,
}),
member,
..
} => {
assert!(member.is_none());
assert_eq!(role.as_deref(), Some("architect"));
assert!(!all_roles);
assert_eq!(before, Some(123));
assert!(older_than.is_none());
assert!(!all);
}
other => panic!("expected inbox purge command, got {other:?}"),
}
}
#[test]
fn inbox_purge_subcommand_parses_all_roles_and_all() {
let cli = Cli::parse_from(["batty", "inbox", "purge", "--all-roles", "--all"]);
match cli.command {
Command::Inbox {
command:
Some(InboxCommand::Purge {
role,
all_roles,
before,
older_than,
all,
}),
member,
..
} => {
assert!(member.is_none());
assert!(role.is_none());
assert!(all_roles);
assert_eq!(before, None);
assert!(older_than.is_none());
assert!(all);
}
other => panic!("expected inbox purge command, got {other:?}"),
}
}
#[test]
fn inbox_purge_subcommand_parses_older_than() {
let cli = Cli::parse_from(["batty", "inbox", "purge", "eng-1", "--older-than", "24h"]);
match cli.command {
Command::Inbox {
command:
Some(InboxCommand::Purge {
role,
all_roles,
before,
older_than,
all,
}),
..
} => {
assert_eq!(role.as_deref(), Some("eng-1"));
assert!(!all_roles);
assert_eq!(before, None);
assert_eq!(older_than.as_deref(), Some("24h"));
assert!(!all);
}
other => panic!("expected inbox purge command, got {other:?}"),
}
}
#[test]
fn inbox_purge_rejects_older_than_with_before() {
let result = Cli::try_parse_from([
"batty",
"inbox",
"purge",
"eng-1",
"--older-than",
"24h",
"--before",
"100",
]);
assert!(result.is_err());
}
#[test]
fn inbox_purge_rejects_older_than_with_all() {
let result = Cli::try_parse_from([
"batty",
"inbox",
"purge",
"eng-1",
"--older-than",
"24h",
"--all",
]);
assert!(result.is_err());
}
#[test]
fn read_subcommand_parses_member_and_id() {
let cli = Cli::parse_from(["batty", "read", "architect", "abc123"]);
match cli.command {
Command::Read { member, id } => {
assert_eq!(member, "architect");
assert_eq!(id, "abc123");
}
other => panic!("expected read command, got {other:?}"),
}
}
#[test]
fn ack_subcommand_parses_member_and_id() {
let cli = Cli::parse_from(["batty", "ack", "eng-1-1", "abc123"]);
match cli.command {
Command::Ack { member, id } => {
assert_eq!(member, "eng-1-1");
assert_eq!(id, "abc123");
}
other => panic!("expected ack command, got {other:?}"),
}
}
#[test]
fn pause_subcommand_parses() {
let cli = Cli::parse_from(["batty", "pause"]);
assert!(matches!(cli.command, Command::Pause));
}
#[test]
fn resume_subcommand_parses() {
let cli = Cli::parse_from(["batty", "resume"]);
assert!(matches!(cli.command, Command::Resume));
}
#[test]
fn telegram_subcommand_parses() {
let cli = Cli::parse_from(["batty", "telegram"]);
assert!(matches!(cli.command, Command::Telegram));
}
#[test]
fn discord_subcommand_defaults_to_setup_flow() {
let cli = Cli::parse_from(["batty", "discord"]);
assert!(matches!(cli.command, Command::Discord { command: None }));
}
#[test]
fn discord_status_subcommand_parses() {
let cli = Cli::parse_from(["batty", "discord", "status"]);
assert!(matches!(
cli.command,
Command::Discord {
command: Some(DiscordCommand::Status)
}
));
}
#[test]
fn project_register_subcommand_parses() {
let cli = Cli::parse_from([
"batty",
"project",
"register",
"--project-id",
"batty-core",
"--name",
"Batty Core",
"--project-root",
"/tmp/batty",
"--board-dir",
"/tmp/batty/.batty/team_config/board",
"--team-name",
"batty",
"--session-name",
"batty-batty",
"--owner",
"platform",
"--tag",
"openclaw",
"--channel-binding",
"telegram=chat:123",
"--allow-openclaw-supervision",
"--json",
]);
match cli.command {
Command::Project {
command:
ProjectCommand::Register {
project_id,
name,
aliases,
project_root,
board_dir,
team_name,
session_name,
owner,
tags,
channel_bindings,
thread_bindings,
allow_openclaw_supervision,
allow_cross_project_routing,
allow_shared_service_routing,
archived,
json,
},
} => {
assert_eq!(project_id, "batty-core");
assert_eq!(name, "Batty Core");
assert_eq!(project_root, PathBuf::from("/tmp/batty"));
assert_eq!(
board_dir,
PathBuf::from("/tmp/batty/.batty/team_config/board")
);
assert_eq!(team_name, "batty");
assert_eq!(session_name, "batty-batty");
assert_eq!(owner.as_deref(), Some("platform"));
assert!(aliases.is_empty());
assert_eq!(tags, vec!["openclaw"]);
assert_eq!(channel_bindings, vec!["telegram=chat:123"]);
assert!(thread_bindings.is_empty());
assert!(allow_openclaw_supervision);
assert!(!allow_cross_project_routing);
assert!(!allow_shared_service_routing);
assert!(!archived);
assert!(json);
}
other => panic!("expected project register command, got {other:?}"),
}
}
#[test]
fn project_get_subcommand_parses() {
let cli = Cli::parse_from(["batty", "project", "get", "batty-core", "--json"]);
match cli.command {
Command::Project {
command: ProjectCommand::Get { project_id, json },
} => {
assert_eq!(project_id, "batty-core");
assert!(json);
}
other => panic!("expected project get command, got {other:?}"),
}
}
#[test]
fn project_lifecycle_subcommands_parse() {
let start = Cli::parse_from(["batty", "project", "start", "batty-core", "--json"]);
assert!(matches!(
start.command,
Command::Project {
command: ProjectCommand::Start {
project_id,
json: true
}
} if project_id == "batty-core"
));
let stop = Cli::parse_from(["batty", "project", "stop", "batty-core"]);
assert!(matches!(
stop.command,
Command::Project {
command: ProjectCommand::Stop {
project_id,
json: false
}
} if project_id == "batty-core"
));
let restart = Cli::parse_from(["batty", "project", "restart", "batty-core"]);
assert!(matches!(
restart.command,
Command::Project {
command: ProjectCommand::Restart {
project_id,
json: false
}
} if project_id == "batty-core"
));
let status = Cli::parse_from(["batty", "project", "status", "batty-core"]);
assert!(matches!(
status.command,
Command::Project {
command: ProjectCommand::Status {
project_id,
json: false
}
} if project_id == "batty-core"
));
}
#[test]
fn project_set_active_subcommand_parses_thread_scope() {
let cli = Cli::parse_from([
"batty",
"project",
"set-active",
"batty-core",
"--channel",
"slack",
"--binding",
"channel:C123",
"--thread-binding",
"thread:abc",
]);
match cli.command {
Command::Project {
command:
ProjectCommand::SetActive {
project_id,
channel,
binding,
thread_binding,
json,
},
} => {
assert_eq!(project_id, "batty-core");
assert_eq!(channel.as_deref(), Some("slack"));
assert_eq!(binding.as_deref(), Some("channel:C123"));
assert_eq!(thread_binding.as_deref(), Some("thread:abc"));
assert!(!json);
}
other => panic!("expected project set-active command, got {other:?}"),
}
}
#[test]
fn project_resolve_subcommand_parses() {
let cli = Cli::parse_from([
"batty",
"project",
"resolve",
"check batty",
"--channel",
"telegram",
"--binding",
"chat:123",
"--json",
]);
match cli.command {
Command::Project {
command:
ProjectCommand::Resolve {
message,
channel,
binding,
thread_binding,
json,
},
} => {
assert_eq!(message, "check batty");
assert_eq!(channel.as_deref(), Some("telegram"));
assert_eq!(binding.as_deref(), Some("chat:123"));
assert!(thread_binding.is_none());
assert!(json);
}
other => panic!("expected project resolve command, got {other:?}"),
}
}
#[test]
fn doctor_subcommand_parses() {
let cli = Cli::parse_from(["batty", "doctor"]);
assert!(matches!(
cli.command,
Command::Doctor {
fix: false,
yes: false
}
));
}
#[test]
fn doctor_subcommand_parses_fix_flag() {
let cli = Cli::parse_from(["batty", "doctor", "--fix"]);
assert!(matches!(
cli.command,
Command::Doctor {
fix: true,
yes: false
}
));
}
#[test]
fn doctor_subcommand_parses_fix_yes_flags() {
let cli = Cli::parse_from(["batty", "doctor", "--fix", "--yes"]);
assert!(matches!(
cli.command,
Command::Doctor {
fix: true,
yes: true
}
));
}
#[test]
fn worktree_health_command_parses() {
let cli = Cli::parse_from(["batty", "worktree", "--health"]);
assert!(matches!(cli.command, Command::Worktree { health: true }));
}
#[test]
fn load_subcommand_parses() {
let cli = Cli::parse_from(["batty", "load"]);
assert!(matches!(cli.command, Command::Load));
}
#[test]
fn parity_subcommand_parses_defaults() {
let cli = Cli::parse_from(["batty", "parity"]);
match cli.command {
Command::Parity { detail, gaps } => {
assert!(!detail);
assert!(!gaps);
}
other => panic!("expected parity command, got {other:?}"),
}
}
#[test]
fn parity_subcommand_parses_gaps_flag() {
let cli = Cli::parse_from(["batty", "parity", "--gaps"]);
match cli.command {
Command::Parity { detail, gaps } => {
assert!(!detail);
assert!(gaps);
}
other => panic!("expected parity command, got {other:?}"),
}
}
#[test]
fn verify_subcommand_parses() {
let cli = Cli::parse_from(["batty", "verify"]);
assert!(matches!(cli.command, Command::Verify));
}
#[test]
fn release_subcommand_parses_defaults() {
let cli = Cli::parse_from(["batty", "release"]);
match cli.command {
Command::Release { tag, readiness } => {
assert!(tag.is_none());
assert!(!readiness);
}
other => panic!("expected release command, got {other:?}"),
}
}
#[test]
fn release_subcommand_parses_tag_override() {
let cli = Cli::parse_from(["batty", "release", "--tag", "batty-2026-04-10"]);
match cli.command {
Command::Release { tag, readiness } => {
assert_eq!(tag.as_deref(), Some("batty-2026-04-10"));
assert!(!readiness);
}
other => panic!("expected release command, got {other:?}"),
}
}
#[test]
fn release_subcommand_parses_readiness_flag() {
let cli = Cli::parse_from(["batty", "release", "--readiness"]);
match cli.command {
Command::Release { tag, readiness } => {
assert!(tag.is_none());
assert!(readiness);
}
other => panic!("expected release command, got {other:?}"),
}
}
#[test]
fn queue_subcommand_parses() {
let cli = Cli::parse_from(["batty", "queue"]);
assert!(matches!(cli.command, Command::Queue));
}
#[test]
fn dispatch_explain_subcommand_parses() {
let cli = Cli::parse_from(["batty", "dispatch", "--explain", "--task", "42"]);
match cli.command {
Command::Dispatch { explain, task } => {
assert!(explain);
assert_eq!(task, Some(42));
}
other => panic!("expected dispatch command, got {other:?}"),
}
}
#[test]
fn cost_subcommand_parses() {
let cli = Cli::parse_from(["batty", "cost"]);
assert!(matches!(cli.command, Command::Cost));
}
#[test]
fn verbose_flag_is_global() {
let cli = Cli::parse_from(["batty", "-vv", "status"]);
assert_eq!(cli.verbose, 2);
}
#[test]
fn task_transition_subcommand_parses() {
let cli = Cli::parse_from(["batty", "task", "transition", "24", "in-progress"]);
match cli.command {
Command::Task {
command:
TaskCommand::Transition {
task_id,
target_state,
},
} => {
assert_eq!(task_id, 24);
assert_eq!(target_state, TaskStateArg::InProgress);
}
other => panic!("expected task transition command, got {other:?}"),
}
}
#[test]
fn task_assign_subcommand_parses() {
let cli = Cli::parse_from([
"batty",
"task",
"assign",
"24",
"--execution-owner",
"eng-1-2",
"--review-owner",
"manager-1",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Assign {
task_id,
execution_owner,
review_owner,
},
} => {
assert_eq!(task_id, 24);
assert_eq!(execution_owner.as_deref(), Some("eng-1-2"));
assert_eq!(review_owner.as_deref(), Some("manager-1"));
}
other => panic!("expected task assign command, got {other:?}"),
}
}
#[test]
fn task_review_subcommand_parses() {
let cli = Cli::parse_from([
"batty",
"task",
"review",
"24",
"--disposition",
"changes_requested",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Review {
task_id,
disposition,
feedback,
},
} => {
assert_eq!(task_id, 24);
assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
assert!(feedback.is_none());
}
other => panic!("expected task review command, got {other:?}"),
}
}
#[test]
fn task_update_subcommand_parses() {
let cli = Cli::parse_from([
"batty",
"task",
"update",
"24",
"--branch",
"eng-1-2/task-24",
"--commit",
"abc1234",
"--blocked-on",
"waiting for review",
"--clear-blocked",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Update {
task_id,
branch,
commit,
blocked_on,
clear_blocked,
},
} => {
assert_eq!(task_id, 24);
assert_eq!(branch.as_deref(), Some("eng-1-2/task-24"));
assert_eq!(commit.as_deref(), Some("abc1234"));
assert_eq!(blocked_on.as_deref(), Some("waiting for review"));
assert!(clear_blocked);
}
other => panic!("expected task update command, got {other:?}"),
}
}
#[test]
fn nudge_disable_parses() {
let cli = Cli::parse_from(["batty", "nudge", "disable", "triage"]);
match cli.command {
Command::Nudge {
command: NudgeCommand::Disable { name },
} => assert_eq!(name, NudgeIntervention::Triage),
other => panic!("expected nudge disable, got {other:?}"),
}
}
#[test]
fn nudge_enable_parses() {
let cli = Cli::parse_from(["batty", "nudge", "enable", "replenish"]);
match cli.command {
Command::Nudge {
command: NudgeCommand::Enable { name },
} => assert_eq!(name, NudgeIntervention::Replenish),
other => panic!("expected nudge enable, got {other:?}"),
}
}
#[test]
fn nudge_status_parses() {
let cli = Cli::parse_from(["batty", "nudge", "status"]);
match cli.command {
Command::Nudge {
command: NudgeCommand::Status,
} => {}
other => panic!("expected nudge status, got {other:?}"),
}
}
#[test]
fn nudge_disable_owned_task_parses() {
let cli = Cli::parse_from(["batty", "nudge", "disable", "owned-task"]);
match cli.command {
Command::Nudge {
command: NudgeCommand::Disable { name },
} => assert_eq!(name, NudgeIntervention::OwnedTask),
other => panic!("expected nudge disable owned-task, got {other:?}"),
}
}
#[test]
fn nudge_rejects_unknown_intervention() {
let result = Cli::try_parse_from(["batty", "nudge", "disable", "unknown"]);
assert!(result.is_err());
}
#[test]
fn nudge_intervention_marker_names() {
assert_eq!(NudgeIntervention::Replenish.marker_name(), "replenish");
assert_eq!(NudgeIntervention::Triage.marker_name(), "triage");
assert_eq!(NudgeIntervention::Review.marker_name(), "review");
assert_eq!(NudgeIntervention::Dispatch.marker_name(), "dispatch");
assert_eq!(NudgeIntervention::Utilization.marker_name(), "utilization");
assert_eq!(NudgeIntervention::OwnedTask.marker_name(), "owned-task");
}
#[test]
fn parse_task_schedule_at() {
let cli = Cli::parse_from([
"batty",
"task",
"schedule",
"50",
"--at",
"2026-03-25T09:00:00-04:00",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Schedule {
task_id,
at,
cron,
clear,
},
} => {
assert_eq!(task_id, 50);
assert_eq!(at.as_deref(), Some("2026-03-25T09:00:00-04:00"));
assert!(cron.is_none());
assert!(!clear);
}
other => panic!("expected task schedule command, got {other:?}"),
}
}
#[test]
fn parse_task_schedule_cron() {
let cli = Cli::parse_from(["batty", "task", "schedule", "51", "--cron", "0 9 * * *"]);
match cli.command {
Command::Task {
command:
TaskCommand::Schedule {
task_id,
at,
cron,
clear,
},
} => {
assert_eq!(task_id, 51);
assert!(at.is_none());
assert_eq!(cron.as_deref(), Some("0 9 * * *"));
assert!(!clear);
}
other => panic!("expected task schedule command, got {other:?}"),
}
}
#[test]
fn parse_task_schedule_clear() {
let cli = Cli::parse_from(["batty", "task", "schedule", "52", "--clear"]);
match cli.command {
Command::Task {
command:
TaskCommand::Schedule {
task_id,
at,
cron,
clear,
},
} => {
assert_eq!(task_id, 52);
assert!(at.is_none());
assert!(cron.is_none());
assert!(clear);
}
other => panic!("expected task schedule command, got {other:?}"),
}
}
#[test]
fn parse_task_schedule_both() {
let cli = Cli::parse_from([
"batty",
"task",
"schedule",
"53",
"--at",
"2026-04-01T00:00:00Z",
"--cron",
"0 9 * * 1",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Schedule {
task_id,
at,
cron,
clear,
},
} => {
assert_eq!(task_id, 53);
assert_eq!(at.as_deref(), Some("2026-04-01T00:00:00Z"));
assert_eq!(cron.as_deref(), Some("0 9 * * 1"));
assert!(!clear);
}
other => panic!("expected task schedule command, got {other:?}"),
}
}
#[test]
fn review_approve_parses() {
let cli = Cli::parse_from(["batty", "review", "42", "approve"]);
match cli.command {
Command::Review {
task_id,
disposition,
feedback,
reviewer,
} => {
assert_eq!(task_id, 42);
assert_eq!(disposition, ReviewAction::Approve);
assert!(feedback.is_none());
assert_eq!(reviewer, "human");
}
other => panic!("expected review command, got {other:?}"),
}
}
#[test]
fn review_request_changes_with_feedback_parses() {
let cli = Cli::parse_from([
"batty",
"review",
"99",
"request-changes",
"fix the error handling",
]);
match cli.command {
Command::Review {
task_id,
disposition,
feedback,
reviewer,
} => {
assert_eq!(task_id, 99);
assert_eq!(disposition, ReviewAction::RequestChanges);
assert_eq!(feedback.as_deref(), Some("fix the error handling"));
assert_eq!(reviewer, "human");
}
other => panic!("expected review command, got {other:?}"),
}
}
#[test]
fn review_reject_with_reviewer_flag_parses() {
let cli = Cli::parse_from([
"batty",
"review",
"7",
"reject",
"does not meet requirements",
"--reviewer",
"manager-1",
]);
match cli.command {
Command::Review {
task_id,
disposition,
feedback,
reviewer,
} => {
assert_eq!(task_id, 7);
assert_eq!(disposition, ReviewAction::Reject);
assert_eq!(feedback.as_deref(), Some("does not meet requirements"));
assert_eq!(reviewer, "manager-1");
}
other => panic!("expected review command, got {other:?}"),
}
}
#[test]
fn review_rejects_invalid_disposition() {
let result = Cli::try_parse_from(["batty", "review", "42", "maybe"]);
assert!(result.is_err());
}
#[test]
fn send_rejects_missing_role() {
let result = Cli::try_parse_from(["batty", "send"]);
assert!(result.is_err());
}
#[test]
fn send_rejects_missing_message() {
let result = Cli::try_parse_from(["batty", "send", "architect"]);
assert!(result.is_err());
}
#[test]
fn assign_rejects_missing_engineer() {
let result = Cli::try_parse_from(["batty", "assign"]);
assert!(result.is_err());
}
#[test]
fn assign_rejects_missing_task() {
let result = Cli::try_parse_from(["batty", "assign", "eng-1-1"]);
assert!(result.is_err());
}
#[test]
fn review_rejects_missing_task_id() {
let result = Cli::try_parse_from(["batty", "review"]);
assert!(result.is_err());
}
#[test]
fn review_rejects_missing_disposition() {
let result = Cli::try_parse_from(["batty", "review", "42"]);
assert!(result.is_err());
}
#[test]
fn merge_rejects_missing_engineer() {
let result = Cli::try_parse_from(["batty", "merge"]);
assert!(result.is_err());
}
#[test]
fn read_rejects_missing_member() {
let result = Cli::try_parse_from(["batty", "read"]);
assert!(result.is_err());
}
#[test]
fn read_rejects_missing_id() {
let result = Cli::try_parse_from(["batty", "read", "architect"]);
assert!(result.is_err());
}
#[test]
fn ack_rejects_missing_args() {
let result = Cli::try_parse_from(["batty", "ack"]);
assert!(result.is_err());
}
#[test]
fn telemetry_summary_parses() {
let cli = Cli::parse_from(["batty", "telemetry", "summary"]);
match cli.command {
Command::Telemetry {
command: TelemetryCommand::Summary,
} => {}
other => panic!("expected telemetry summary, got {other:?}"),
}
}
#[test]
fn telemetry_agents_parses() {
let cli = Cli::parse_from(["batty", "telemetry", "agents"]);
match cli.command {
Command::Telemetry {
command: TelemetryCommand::Agents,
} => {}
other => panic!("expected telemetry agents, got {other:?}"),
}
}
#[test]
fn telemetry_tasks_parses() {
let cli = Cli::parse_from(["batty", "telemetry", "tasks"]);
match cli.command {
Command::Telemetry {
command: TelemetryCommand::Tasks,
} => {}
other => panic!("expected telemetry tasks, got {other:?}"),
}
}
#[test]
fn telemetry_reviews_parses() {
let cli = Cli::parse_from(["batty", "telemetry", "reviews"]);
match cli.command {
Command::Telemetry {
command: TelemetryCommand::Reviews,
} => {}
other => panic!("expected telemetry reviews, got {other:?}"),
}
}
#[test]
fn telemetry_events_default_limit() {
let cli = Cli::parse_from(["batty", "telemetry", "events"]);
match cli.command {
Command::Telemetry {
command: TelemetryCommand::Events { limit },
} => assert_eq!(limit, 50),
other => panic!("expected telemetry events, got {other:?}"),
}
}
#[test]
fn telemetry_events_custom_limit() {
let cli = Cli::parse_from(["batty", "telemetry", "events", "-n", "10"]);
match cli.command {
Command::Telemetry {
command: TelemetryCommand::Events { limit },
} => assert_eq!(limit, 10),
other => panic!("expected telemetry events with limit, got {other:?}"),
}
}
#[test]
fn telemetry_rejects_missing_subcommand() {
let result = Cli::try_parse_from(["batty", "telemetry"]);
assert!(result.is_err());
}
#[test]
fn grafana_setup_parses() {
let cli = Cli::parse_from(["batty", "grafana", "setup"]);
assert!(matches!(
cli.command,
Command::Grafana {
command: GrafanaCommand::Setup
}
));
}
#[test]
fn grafana_status_parses() {
let cli = Cli::parse_from(["batty", "grafana", "status"]);
assert!(matches!(
cli.command,
Command::Grafana {
command: GrafanaCommand::Status
}
));
}
#[test]
fn grafana_open_parses() {
let cli = Cli::parse_from(["batty", "grafana", "open"]);
assert!(matches!(
cli.command,
Command::Grafana {
command: GrafanaCommand::Open
}
));
}
#[test]
fn grafana_rejects_missing_subcommand() {
let result = Cli::try_parse_from(["batty", "grafana"]);
assert!(result.is_err());
}
#[test]
fn task_auto_merge_enable_parses() {
let cli = Cli::parse_from(["batty", "task", "auto-merge", "30", "enable"]);
match cli.command {
Command::Task {
command: TaskCommand::AutoMerge { task_id, action },
} => {
assert_eq!(task_id, 30);
assert_eq!(action, AutoMergeAction::Enable);
}
other => panic!("expected task auto-merge enable, got {other:?}"),
}
}
#[test]
fn task_auto_merge_disable_parses() {
let cli = Cli::parse_from(["batty", "task", "auto-merge", "31", "disable"]);
match cli.command {
Command::Task {
command: TaskCommand::AutoMerge { task_id, action },
} => {
assert_eq!(task_id, 31);
assert_eq!(action, AutoMergeAction::Disable);
}
other => panic!("expected task auto-merge disable, got {other:?}"),
}
}
#[test]
fn task_auto_merge_rejects_invalid_action() {
let result = Cli::try_parse_from(["batty", "task", "auto-merge", "30", "toggle"]);
assert!(result.is_err());
}
#[test]
fn task_assign_execution_owner_only() {
let cli = Cli::parse_from([
"batty",
"task",
"assign",
"10",
"--execution-owner",
"eng-1-3",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Assign {
task_id,
execution_owner,
review_owner,
},
} => {
assert_eq!(task_id, 10);
assert_eq!(execution_owner.as_deref(), Some("eng-1-3"));
assert!(review_owner.is_none());
}
other => panic!("expected task assign command, got {other:?}"),
}
}
#[test]
fn task_rejects_missing_subcommand() {
let result = Cli::try_parse_from(["batty", "task"]);
assert!(result.is_err());
}
#[test]
fn doctor_rejects_yes_without_fix() {
let result = Cli::try_parse_from(["batty", "doctor", "--yes"]);
assert!(result.is_err());
}
#[test]
fn daemon_subcommand_parses() {
let cli = Cli::parse_from(["batty", "daemon", "--project-root", "/tmp/project"]);
match cli.command {
Command::Daemon {
project_root,
resume,
} => {
assert_eq!(project_root, "/tmp/project");
assert!(!resume);
}
other => panic!("expected daemon command, got {other:?}"),
}
}
#[test]
fn daemon_subcommand_parses_resume_flag() {
let cli = Cli::parse_from([
"batty",
"daemon",
"--project-root",
"/tmp/project",
"--resume",
]);
match cli.command {
Command::Daemon {
project_root,
resume,
} => {
assert_eq!(project_root, "/tmp/project");
assert!(resume);
}
other => panic!("expected daemon command with resume, got {other:?}"),
}
}
#[test]
fn daemon_restart_if_stale_subcommand_parses() {
let cli = Cli::parse_from(["batty", "daemon-restart-if-stale", "--dry-run"]);
match cli.command {
Command::DaemonRestartIfStale { dry_run } => {
assert!(dry_run);
}
other => panic!("expected daemon-restart-if-stale command, got {other:?}"),
}
}
#[test]
fn watchdog_subcommand_parses() {
let cli = Cli::parse_from(["batty", "watchdog", "--project-root", "/tmp/project"]);
match cli.command {
Command::Watchdog {
project_root,
resume,
} => {
assert_eq!(project_root, "/tmp/project");
assert!(!resume);
}
other => panic!("expected watchdog command, got {other:?}"),
}
}
#[test]
fn watchdog_subcommand_parses_resume_flag() {
let cli = Cli::parse_from([
"batty",
"watchdog",
"--project-root",
"/tmp/project",
"--resume",
]);
match cli.command {
Command::Watchdog {
project_root,
resume,
} => {
assert_eq!(project_root, "/tmp/project");
assert!(resume);
}
other => panic!("expected watchdog command with resume, got {other:?}"),
}
}
#[test]
fn completions_all_shells_parse() {
for (arg, expected) in [
("bash", CompletionShell::Bash),
("zsh", CompletionShell::Zsh),
("fish", CompletionShell::Fish),
] {
let cli = Cli::parse_from(["batty", "completions", arg]);
match cli.command {
Command::Completions { shell } => assert_eq!(shell, expected, "shell arg={arg}"),
other => panic!("expected completions command for {arg}, got {other:?}"),
}
}
}
#[test]
fn completions_rejects_unknown_shell() {
let result = Cli::try_parse_from(["batty", "completions", "powershell"]);
assert!(result.is_err());
}
#[test]
fn init_all_template_variants() {
for (arg, expected) in [
("solo", InitTemplate::Solo),
("pair", InitTemplate::Pair),
("simple", InitTemplate::Simple),
("squad", InitTemplate::Squad),
("large", InitTemplate::Large),
("research", InitTemplate::Research),
("software", InitTemplate::Software),
("cleanroom", InitTemplate::Cleanroom),
("batty", InitTemplate::Batty),
("python", InitTemplate::Python),
] {
let cli = Cli::parse_from(["batty", "init", "--template", arg]);
match cli.command {
Command::Init { template, from, .. } => {
assert_eq!(template, Some(expected), "template arg={arg}");
assert!(from.is_none());
}
other => panic!("expected init command for template {arg}, got {other:?}"),
}
}
}
#[test]
fn task_review_with_feedback_parses() {
let cli = Cli::parse_from([
"batty",
"task",
"review",
"15",
"--disposition",
"changes_requested",
"--feedback",
"please fix tests",
]);
match cli.command {
Command::Task {
command:
TaskCommand::Review {
task_id,
disposition,
feedback,
},
} => {
assert_eq!(task_id, 15);
assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
assert_eq!(feedback.as_deref(), Some("please fix tests"));
}
other => panic!("expected task review command, got {other:?}"),
}
}
#[test]
fn task_transition_all_states() {
for (arg, expected) in [
("backlog", TaskStateArg::Backlog),
("todo", TaskStateArg::Todo),
("in-progress", TaskStateArg::InProgress),
("review", TaskStateArg::Review),
("blocked", TaskStateArg::Blocked),
("done", TaskStateArg::Done),
("archived", TaskStateArg::Archived),
] {
let cli = Cli::parse_from(["batty", "task", "transition", "1", arg]);
match cli.command {
Command::Task {
command:
TaskCommand::Transition {
task_id,
target_state,
},
} => {
assert_eq!(task_id, 1);
assert_eq!(target_state, expected, "state arg={arg}");
}
other => panic!("expected task transition for {arg}, got {other:?}"),
}
}
}
#[test]
fn task_transition_rejects_invalid_state() {
let result = Cli::try_parse_from(["batty", "task", "transition", "1", "cancelled"]);
assert!(result.is_err());
}
#[test]
fn rejects_unknown_subcommand() {
let result = Cli::try_parse_from(["batty", "foobar"]);
assert!(result.is_err());
}
#[test]
fn rejects_no_subcommand() {
let result = Cli::try_parse_from(["batty"]);
assert!(result.is_err());
}
#[test]
fn inbox_purge_rejects_missing_role_and_all_roles() {
let result = Cli::try_parse_from(["batty", "inbox", "purge", "--all"]);
assert!(result.is_err());
}
#[test]
fn nudge_enable_all_interventions() {
for (arg, expected) in [
("replenish", NudgeIntervention::Replenish),
("triage", NudgeIntervention::Triage),
("review", NudgeIntervention::Review),
("dispatch", NudgeIntervention::Dispatch),
("utilization", NudgeIntervention::Utilization),
("owned-task", NudgeIntervention::OwnedTask),
] {
let cli = Cli::parse_from(["batty", "nudge", "enable", arg]);
match cli.command {
Command::Nudge {
command: NudgeCommand::Enable { name },
} => assert_eq!(name, expected, "nudge enable arg={arg}"),
other => panic!("expected nudge enable for {arg}, got {other:?}"),
}
}
}
#[test]
fn config_subcommand_defaults_no_json() {
let cli = Cli::parse_from(["batty", "config"]);
match cli.command {
Command::Config { json } => assert!(!json),
other => panic!("expected config command, got {other:?}"),
}
}
fn generate_completions(shell: clap_complete::Shell) -> String {
use clap::CommandFactory;
let mut buf = Vec::new();
clap_complete::generate(shell, &mut Cli::command(), "batty", &mut buf);
String::from_utf8(buf).expect("completions should be valid UTF-8")
}
#[test]
fn completions_bash_generates() {
let output = generate_completions(clap_complete::Shell::Bash);
assert!(!output.is_empty(), "bash completions should not be empty");
assert!(
output.contains("_batty"),
"bash completions should define _batty function"
);
}
#[test]
fn completions_zsh_generates() {
let output = generate_completions(clap_complete::Shell::Zsh);
assert!(!output.is_empty(), "zsh completions should not be empty");
assert!(
output.contains("#compdef batty"),
"zsh completions should start with #compdef"
);
}
#[test]
fn completions_fish_generates() {
let output = generate_completions(clap_complete::Shell::Fish);
assert!(!output.is_empty(), "fish completions should not be empty");
assert!(
output.contains("complete -c batty"),
"fish completions should contain complete -c batty"
);
}
#[test]
fn completions_include_grafana_subcommands() {
let output = generate_completions(clap_complete::Shell::Fish);
assert!(
output.contains("grafana"),
"completions should include grafana command"
);
assert!(
output.contains("setup"),
"completions should include grafana setup"
);
assert!(
output.contains("status"),
"completions should include grafana status"
);
assert!(
output.contains("open"),
"completions should include grafana open"
);
}
#[test]
fn completions_include_all_recent_commands() {
let output = generate_completions(clap_complete::Shell::Fish);
let expected_commands = [
"task",
"metrics",
"grafana",
"telemetry",
"nudge",
"load",
"queue",
"cost",
"doctor",
"pause",
"resume",
];
for cmd in &expected_commands {
assert!(
output.contains(cmd),
"completions should include '{cmd}' command"
);
}
}
}