Skip to main content

xbp_cli/cli/
commands.rs

1use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Parser, Debug)]
6#[command(
7    name = "xbp",
8    version,
9    // Custom version flag so `-v` works (clap's built-in short is only `-V`).
10    disable_version_flag = true,
11    about = "Deploy, operate, and debug services with one CLI.",
12    long_about = "XBP is an operations-first CLI for deployments, diagnostics, service orchestration,\nnetwork controls, and runtime observability.",
13    disable_help_subcommand = false,
14    next_line_help = true,
15    help_template = crate::cli::help_render::XBP_ROOT_HELP_TEMPLATE,
16    after_help = crate::cli::help_render::XBP_ROOT_AFTER_HELP
17)]
18pub struct Cli {
19    /// Print version
20    #[arg(
21        // Id must not be `version` — that collides with the `version` subcommand name.
22        short = 'v',
23        long = "version",
24        visible_short_alias = 'V',
25        action = ArgAction::Version
26    )]
27    print_version: Option<bool>,
28    #[arg(long, global = true, help = "Enable verbose debugging output")]
29    pub debug: bool,
30    #[arg(
31        long = "push",
32        global = true,
33        help = "Push after auto-committing generated changes (also honors `github.auto_push_on_commit` when omitted)"
34    )]
35    pub push: bool,
36    #[arg(short = 'l', help = "List pm2 processes")]
37    pub list: bool,
38    #[arg(short = 'p', long = "port", help = "Filter by port number")]
39    pub port: Option<u16>,
40    #[arg(long, help = "Open logs directory")]
41    pub logs: bool,
42    #[arg(long, help = "Print the complete alphabetical command reference")]
43    pub commands: bool,
44
45    #[command(subcommand)]
46    pub command: Option<Commands>,
47}
48
49#[derive(Subcommand, Debug)]
50pub enum Commands {
51    #[command(about = "Inspect or manage listening ports")]
52    Ports(PortsCmd),
53    #[command(
54        about = "Analyze the current git worktree and create a conventional commit",
55        visible_alias = "c"
56    )]
57    Commit(CommitCmd),
58    #[command(
59        about = "Sync with remote (stash-safe rebase when behind/diverged) and push the current branch",
60        long_about = "Automates the recover-and-push flow when plain git fails:\n\
61  • non-fast-forward push rejection\n\
62  • diverging branches under pull.ff=only\n\
63  • dirty unstaged/untracked WIP blocking rebase\n\n\
64Steps: fetch → stash dirty WIP if needed → pull --rebase → push → restore stash.\n\
65Does not create commits; use `xbp commit --push` to commit local changes first.",
66        after_help = "Examples:\n  xbp push\n  xbp commit --push   # commit dirty files, then same sync+push path"
67    )]
68    Push,
69    #[command(
70        about = "Plan/run/verify service deploys from services[] (groups via deploy.groups)",
71        long_about = "Service-first deploy orchestration.\n\n\
72Targets:\n\
73  • service name from services[]\n\
74  • deploy.groups.<name> (ordered multi-service)\n\
75  • all (every service with deploy.envs.<env>)\n\n\
76Modes (flags):\n\
77  --plan     print plan only (default when no other mode flag)\n\
78  --run      apply providers (kubernetes, kubernetes-operator, worker, …)\n\
79  --verify   read-only rollout/health checks\n\
80  --status   status snapshot\n\
81  --promote  promotion label (runs apply path)\n\
82  --history  list recent deploy records under .xbp/deployments\n\n\
83Does not use product islands like `xbp athena deploy`.",
84        after_help = "Examples:\n  xbp deploy workers --plan\n  xbp deploy workers --env production --run --yes\n  xbp deploy @xylex-group/xbp-api-worker --verify\n  xbp deploy all --env production --plan\n  xbp deploy workers --history"
85    )]
86    Deploy(DeployCmd),
87    #[command(about = "Initialize an XBP project in the current directory")]
88    Init,
89    #[command(about = "Install common dependencies for host setup")]
90    Setup,
91    #[command(about = "Redeploy one service or the entire project")]
92    Redeploy {
93        #[arg(
94            help = "Service name to redeploy (optional, uses legacy redeploy.sh if not provided)"
95        )]
96        service_name: Option<String>,
97    },
98    #[command(about = "Run the legacy remote redeploy workflow over SSH")]
99    RedeployV2(RedeployV2Cmd),
100    #[command(about = "Inspect project/global config and manage provider keys")]
101    Config(ConfigCmd),
102    #[command(
103        about = "Install supported host packages or project tooling",
104        help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
105        after_help = crate::commands::INSTALL_COMMAND_AFTER_HELP
106    )]
107    Install {
108        #[arg(short = 'l', long = "list", help = "List installable targets and exit")]
109        list: bool,
110        #[arg(
111            long = "force",
112            help = "Allow an explicitly selected install method to run even when it does not match the current OS"
113        )]
114        force: bool,
115        #[arg(help = "Install target (leave empty to show installable options)")]
116        package: Option<String>,
117    },
118    #[command(about = "Tail local or remote logs")]
119    Logs(LogsCmd),
120    #[command(
121        about = "Open an interactive remote shell over SSH",
122        visible_alias = "shell"
123    )]
124    Ssh(SshCmd),
125    #[command(about = "Open or manage cloudflared TCP forwarders")]
126    Cloudflared(CloudflaredCmd),
127    #[command(about = "List PM2 processes")]
128    List,
129    #[command(about = "Fetch an HTTP endpoint with sane defaults")]
130    Curl(CurlCmd),
131    #[command(about = "List configured services from project config")]
132    Services,
133    #[command(
134        about = "Run service commands with interactive preview/picker when no args given. Stores run history + registry under global ~/.xbp/logs/service/"
135    )]
136    Service {
137        #[arg(help = "Command (build/install/start/dev/pre). Omit to use interactive picker.")]
138        command: Option<String>,
139        #[arg(help = "Service name (omit for picker)")]
140        service_name: Option<String>,
141    },
142    #[command(about = "Manage NGINX site configs and upstream mappings")]
143    Nginx(NginxCmd),
144    #[command(about = "Manage host network configuration and floating IPs")]
145    Network(NetworkCmd),
146    #[command(about = "Run full system diagnostics and readiness checks")]
147    Diag(DiagCmd),
148    #[command(about = "Run health-check monitoring commands")]
149    Monitor(MonitorCmd),
150    #[command(about = "Capture a PM2 snapshot for later restore")]
151    Snapshot,
152    #[command(about = "Restore PM2 state from dump or latest snapshot")]
153    Resurrect,
154    #[command(about = "Stop a PM2 process by name or stop all")]
155    Stop {
156        #[arg(help = "PM2 process name or 'all' (default: all)")]
157        target: Option<String>,
158    },
159    #[command(about = "Flush PM2 logs globally or for a specific process")]
160    Flush {
161        #[arg(help = "Optional PM2 process name")]
162        target: Option<String>,
163    },
164    #[command(about = "Run or inspect the CLI login flow against the XBP dashboard")]
165    Login(LoginCmd),
166    #[command(about = "Show the current signed-in CLI identity")]
167    Whoami,
168    #[command(
169        about = "Check crates.io for a newer XBP CLI release (and optionally install it)",
170        visible_alias = "upgrade",
171        after_help = "Examples:\n  xbp update\n  xbp update --json\n  xbp update --install\n  xbp update --install --features all\n  xbp update --install --features secrets,linear,docker\n  xbp update --install --no-default-features --features secrets\n  xbp update --fail-if-outdated\n  xbp update --crate xbp\n\n`xbp update --install` defaults to `--features all` (secrets, linear, docker, kubernetes, openapi-gen)."
172    )]
173    Update(UpdateCmd),
174    #[command(
175        about = "Inspect, reconcile, or bump project versions",
176        visible_alias = "v"
177    )]
178    Version(VersionCmd),
179    #[command(about = "Run configured npm/crates publish workflows for the current XBP project")]
180    Publish(PublishCmd),
181    #[command(about = "Show PM2 environment by name or numeric id")]
182    Env {
183        #[arg(help = "PM2 process name or id")]
184        target: String,
185    },
186    #[command(about = "Tail app logs or Kafka logs")]
187    Tail(TailCmd),
188    #[command(about = "Start a binary/process under PM2")]
189    Start {
190        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
191        args: Vec<String>,
192    },
193    #[command(about = "Generate helper artifacts such as systemd units")]
194    Generate(GenerateCmd),
195    #[cfg(feature = "secrets")]
196    #[command(about = "Manage env vars and GitHub Actions environment variables (feature-gated)")]
197    Secrets(SecretsCmd),
198    #[command(
199        about = "Manage Cloudflare Workers secrets, Wrangler config helpers, D1 migrations, and deploy flows",
200        visible_alias = "worker"
201    )]
202    Workers(WorkersCmd),
203    #[command(about = "Run canonical Cloudflare Worker + Container workflows for an XBP project")]
204    Cloudflare(CloudflareCmd),
205    #[command(about = "Manage DNS providers, zones, records, DNSSEC, and settings")]
206    Dns(DnsCmd),
207    #[command(about = "Discover and inspect registered domains")]
208    Domains(DomainsCmd),
209    #[command(
210        about = "Generate 'what did I get done' Markdown report from git commits across repos"
211    )]
212    Done(DoneCmd),
213    #[command(
214        about = "Repair malformed Cursor process-monitor JSON exports",
215        visible_alias = "fix-pm-json"
216    )]
217    FixProcessMonitorJson(FixProcessMonitorJsonCmd),
218    #[command(about = "Upload Cursor local file history to the XBP dashboard")]
219    Cursor(CursorCmd),
220    #[cfg(feature = "kubernetes")]
221    #[command(about = "Experimental Kubernetes cluster manager (feature-gated)")]
222    Kubernetes(KubernetesCmd),
223    #[cfg(feature = "nordvpn")]
224    #[command(about = "NordVPN meshnet setup and passthrough (feature-gated)")]
225    Nordvpn(NordvpnCmd),
226    #[cfg(feature = "monitoring")]
227    Monitoring(MonitoringCmd),
228    #[command(about = "Manage the XBP API server")]
229    Api(ApiCmd),
230    #[command(about = "Manage runner hosts, groups, inventory, and runner jobs")]
231    Runners(RunnersCmd),
232    #[command(about = "Built-in MCP server for AI agents (HTTP/SSE on port 1113)")]
233    Mcp(McpCmd),
234    #[command(
235        about = "Watch git worktree mutations, store local JSONL spools, and sync them to xbp.app",
236        visible_alias = "watch"
237    )]
238    WorktreeWatch(WorktreeWatchCmd),
239    #[cfg(feature = "linear")]
240    #[command(
241        about = "Manage Linear issues. Bare `xbp linear` opens a full-screen vim-style TUI; use list/show/create/… for scripts (feature-gated: linear)"
242    )]
243    Linear(LinearIssuesCmd),
244    #[command(
245        about = "Manage GitHub issues for the current repo. Run without a subcommand for the interactive hub",
246        visible_alias = "gh"
247    )]
248    Github(GithubIssuesCmd),
249    #[command(
250        about = "Scan code markers and idempotently file Linear/GitHub issues (manual only)",
251        visible_alias = "issue"
252    )]
253    Issues(TodosCmd),
254    #[command(
255        about = "Deprecated alias for `xbp issues`: scan TODO/FIXME markers and file issues"
256    )]
257    Todos(TodosCmd),
258    #[cfg(feature = "docker")]
259    #[command(about = "Pass-through wrapper around the Docker CLI")]
260    Docker(DockerCmd),
261}
262
263pub fn command_label(command: &Commands) -> &'static str {
264    match command {
265        Commands::Ports(_) => "ports",
266        Commands::Commit(_) => "commit",
267        Commands::Push => "push",
268        Commands::Deploy(_) => "deploy",
269        Commands::Init => "init",
270        Commands::Setup => "setup",
271        Commands::Redeploy { .. } => "redeploy",
272        Commands::RedeployV2(_) => "redeploy-v2",
273        Commands::Config(_) => "config",
274        Commands::Install { .. } => "install",
275        Commands::Logs(_) => "logs",
276        Commands::Ssh(_) => "ssh",
277        Commands::Cloudflared(_) => "cloudflared",
278        Commands::List => "list",
279        Commands::Curl(_) => "curl",
280        Commands::Services => "services",
281        Commands::Service { .. } => "service",
282        Commands::Nginx(_) => "nginx",
283        Commands::Network(_) => "network",
284        Commands::Diag(_) => "diag",
285        Commands::Monitor(_) => "monitor",
286        Commands::Snapshot => "snapshot",
287        Commands::Resurrect => "resurrect",
288        Commands::Stop { .. } => "stop",
289        Commands::Flush { .. } => "flush",
290        Commands::Login(_) => "login",
291        Commands::Whoami => "whoami",
292        Commands::Update(_) => "update",
293        Commands::Version(_) => "version",
294        Commands::Publish(_) => "publish",
295        Commands::Env { .. } => "env",
296        Commands::Tail(_) => "tail",
297        Commands::Start { .. } => "start",
298        Commands::Generate(_) => "generate",
299        #[cfg(feature = "secrets")]
300        Commands::Secrets(_) => "secrets",
301        Commands::Workers(_) => "workers",
302        Commands::Cloudflare(_) => "cloudflare",
303        Commands::Dns(_) => "dns",
304        Commands::Domains(_) => "domains",
305        Commands::Done(_) => "done",
306        Commands::FixProcessMonitorJson(_) => "fix-process-monitor-json",
307        Commands::Cursor(_) => "cursor",
308        #[cfg(feature = "kubernetes")]
309        Commands::Kubernetes(_) => "kubernetes",
310        #[cfg(feature = "nordvpn")]
311        Commands::Nordvpn(_) => "nordvpn",
312        #[cfg(feature = "monitoring")]
313        Commands::Monitoring(_) => "monitoring",
314        Commands::Api(_) => "api",
315        Commands::Runners(_) => "runners",
316        Commands::Mcp(_) => "mcp",
317        Commands::WorktreeWatch(_) => "worktree-watch",
318        #[cfg(feature = "linear")]
319        Commands::Linear(_) => "linear",
320        Commands::Github(_) => "github",
321        Commands::Issues(_) => "issues",
322        Commands::Todos(_) => "todos",
323        #[cfg(feature = "docker")]
324        Commands::Docker(_) => "docker",
325    }
326}
327
328// ---------------------------------------------------------------------------
329// Linear issues (`xbp linear`) — feature-gated: linear
330// ---------------------------------------------------------------------------
331
332#[cfg(feature = "linear")]
333#[derive(Args, Debug)]
334pub struct LinearIssuesCmd {
335    #[command(subcommand)]
336    pub command: Option<LinearSubCommand>,
337    /// Only issues assigned to you (viewer). Applies to the interactive TUI hub.
338    #[arg(long)]
339    pub me: bool,
340    #[arg(long, help = "Team key, name, or id")]
341    pub team: Option<String>,
342    #[arg(
343        long,
344        help = "State name or type (backlog|unstarted|started|completed|canceled)"
345    )]
346    pub state: Option<String>,
347    #[arg(long, help = "Assignee name, email, id, or `me` (overridden by --me)")]
348    pub assignee: Option<String>,
349    #[arg(long, short = 'q', help = "Filter by title/description substring")]
350    pub query: Option<String>,
351    #[arg(long = "label", help = "Require label name (repeatable; client-side)")]
352    pub labels: Vec<String>,
353    #[arg(
354        long,
355        help = "Include completed/canceled issues (default: open-ish only)"
356    )]
357    pub include_completed: bool,
358    #[arg(
359        long,
360        default_value = "updated",
361        help = "Sort: updated|created|priority|identifier|title|state"
362    )]
363    pub sort: String,
364    #[arg(long, help = "Sort ascending (default for most fields is descending)")]
365    pub asc: bool,
366    #[arg(long, default_value_t = 100, help = "Max issues to fetch for the TUI")]
367    pub limit: usize,
368}
369
370/// Alias used by `linear_cmd` handlers.
371#[cfg(feature = "linear")]
372pub type LinearCmd = LinearIssuesCmd;
373
374#[cfg(feature = "linear")]
375#[derive(Subcommand, Debug)]
376pub enum LinearSubCommand {
377    #[command(about = "List issues")]
378    List(LinearListCmd),
379    #[command(about = "Search Linear issues by title/description/identifier")]
380    Search(LinearSearchCmd),
381    #[command(about = "Show a single issue")]
382    Show(LinearShowCmd),
383    #[command(about = "Create an issue")]
384    Create(LinearCreateCmd),
385    #[command(about = "Edit title/description/priority/state/labels/assignee")]
386    Edit(LinearEditCmd),
387    #[command(about = "Change issue status/workflow state")]
388    Status(LinearStatusCmd),
389    #[command(about = "Change issue priority (0–4 or none|urgent|high|medium|low)")]
390    Priority(LinearPriorityCmd),
391    #[command(about = "Set or multi-select labels")]
392    Labels(LinearLabelsCmd),
393    #[command(about = "Assign or unassign")]
394    Assign(LinearAssignCmd),
395    #[command(
396        about = "Multi-select issues and bulk assign / status / labels / project / cycle / priority"
397    )]
398    Bulk(LinearBulkCmd),
399    #[command(about = "Post a comment")]
400    Comment(LinearCommentCmd),
401    #[command(about = "List comments on an issue")]
402    Comments(LinearCommentsCmd),
403}
404
405#[cfg(feature = "linear")]
406#[derive(Args, Debug)]
407pub struct LinearListCmd {
408    #[arg(long, help = "Only issues assigned to you (viewer)")]
409    pub me: bool,
410    #[arg(long, help = "Team key, name, or id")]
411    pub team: Option<String>,
412    #[arg(
413        long,
414        help = "State name or type (backlog|unstarted|started|completed|canceled)"
415    )]
416    pub state: Option<String>,
417    #[arg(long, help = "Assignee name, email, id, or `me` (overridden by --me)")]
418    pub assignee: Option<String>,
419    #[arg(
420        long,
421        short = 'q',
422        help = "Filter by title/description substring (client-side)"
423    )]
424    pub query: Option<String>,
425    #[arg(long = "label", help = "Require label name (repeatable; client-side)")]
426    pub labels: Vec<String>,
427    #[arg(
428        long,
429        help = "Include completed/canceled issues (default: open-ish only)"
430    )]
431    pub include_completed: bool,
432    #[arg(
433        long,
434        default_value = "updated",
435        help = "Sort: updated|created|priority|identifier|title|state"
436    )]
437    pub sort: String,
438    #[arg(long, help = "Sort ascending")]
439    pub asc: bool,
440    #[arg(long, default_value_t = 50, help = "Max issues to fetch")]
441    pub limit: usize,
442    #[arg(
443        long,
444        help = "Open the full-screen Linear TUI instead of a plain table"
445    )]
446    pub tui: bool,
447}
448
449#[cfg(feature = "linear")]
450#[derive(Args, Debug)]
451pub struct LinearSearchCmd {
452    #[arg(help = "Title, description, or identifier substring")]
453    pub query: String,
454    #[arg(long, help = "Only issues assigned to you (viewer)")]
455    pub me: bool,
456    #[arg(long, help = "Team key, name, or id")]
457    pub team: Option<String>,
458    #[arg(
459        long,
460        help = "State name or type (backlog|unstarted|started|completed|canceled)"
461    )]
462    pub state: Option<String>,
463    #[arg(long, help = "Assignee name, email, id, or `me` (overridden by --me)")]
464    pub assignee: Option<String>,
465    #[arg(long = "label", help = "Require label name (repeatable; client-side)")]
466    pub labels: Vec<String>,
467    #[arg(
468        long,
469        help = "Include completed/canceled issues (default: open-ish only)"
470    )]
471    pub include_completed: bool,
472    #[arg(
473        long,
474        default_value = "updated",
475        help = "Sort: updated|created|priority|identifier|title|state"
476    )]
477    pub sort: String,
478    #[arg(long, help = "Sort ascending")]
479    pub asc: bool,
480    #[arg(long, default_value_t = 50, help = "Max issues to fetch")]
481    pub limit: usize,
482    #[arg(
483        long,
484        help = "Open the full-screen Linear TUI with the search preloaded"
485    )]
486    pub tui: bool,
487}
488
489#[cfg(feature = "linear")]
490#[derive(Args, Debug)]
491pub struct LinearShowCmd {
492    #[arg(help = "Issue id or identifier (e.g. XLX-29)")]
493    pub id: String,
494}
495
496#[cfg(feature = "linear")]
497#[derive(Args, Debug)]
498pub struct LinearCreateCmd {
499    #[arg(long, help = "Issue title")]
500    pub title: Option<String>,
501    #[arg(long, help = "Team key, name, or id")]
502    pub team: Option<String>,
503    #[arg(long, help = "Markdown description")]
504    pub description: Option<String>,
505    #[arg(long, help = "Priority 0–4 or none|urgent|high|medium|low")]
506    pub priority: Option<String>,
507    #[arg(long, help = "Initial state name or id")]
508    pub state: Option<String>,
509    #[arg(long, help = "Assignee name, email, id, or `me`")]
510    pub assignee: Option<String>,
511    #[arg(long = "label", help = "Label name or id (repeatable)")]
512    pub labels: Vec<String>,
513    #[arg(long, help = "Prompt for description when interactive")]
514    pub interactive_body: bool,
515}
516
517#[cfg(feature = "linear")]
518#[derive(Args, Debug)]
519pub struct LinearEditCmd {
520    #[arg(help = "Issue id or identifier")]
521    pub id: String,
522    #[arg(long)]
523    pub title: Option<String>,
524    #[arg(long)]
525    pub description: Option<String>,
526    #[arg(long)]
527    pub priority: Option<String>,
528    #[arg(long)]
529    pub state: Option<String>,
530    #[arg(long)]
531    pub assignee: Option<String>,
532    #[arg(long = "label")]
533    pub labels: Vec<String>,
534}
535
536#[cfg(feature = "linear")]
537#[derive(Args, Debug)]
538pub struct LinearStatusCmd {
539    #[arg(help = "Issue id or identifier")]
540    pub id: String,
541    #[arg(long, help = "State name, type, or id")]
542    pub state: Option<String>,
543}
544
545#[cfg(feature = "linear")]
546#[derive(Args, Debug)]
547pub struct LinearPriorityCmd {
548    #[arg(help = "Issue id or identifier")]
549    pub id: String,
550    #[arg(long, help = "Priority 0–4 or none|urgent|high|medium|low")]
551    pub priority: Option<String>,
552}
553
554#[cfg(feature = "linear")]
555#[derive(Args, Debug)]
556pub struct LinearLabelsCmd {
557    #[arg(help = "Issue id or identifier")]
558    pub id: String,
559    #[arg(long, help = "Label to add (repeatable)")]
560    pub add: Vec<String>,
561    #[arg(long, help = "Label to remove (repeatable)")]
562    pub remove: Vec<String>,
563    #[arg(long, help = "Replace all labels (repeatable)")]
564    pub set: Vec<String>,
565    #[arg(long, help = "Clear all labels")]
566    pub clear: bool,
567}
568
569#[cfg(feature = "linear")]
570#[derive(Args, Debug)]
571pub struct LinearAssignCmd {
572    #[arg(help = "Issue id or identifier")]
573    pub id: String,
574    #[arg(long, help = "Assignee name/email/id/`me`/`none`")]
575    pub assignee: Option<String>,
576}
577
578#[cfg(feature = "linear")]
579#[derive(Args, Debug)]
580pub struct LinearBulkCmd {
581    /// Explicit issue ids/identifiers (repeatable). Skips the multi-select picker when set.
582    #[arg(long = "id", help = "Issue id or identifier (repeatable)")]
583    pub ids: Vec<String>,
584    #[arg(long, help = "Only issues assigned to you (viewer)")]
585    pub me: bool,
586    #[arg(long, help = "Team key, name, or id (candidate filter)")]
587    pub team: Option<String>,
588    #[arg(long, help = "State name or type filter for candidates")]
589    pub state: Option<String>,
590    #[arg(long, help = "Assignee filter for candidates (`me`/name/email/id)")]
591    pub assignee: Option<String>,
592    #[arg(long, short = 'q', help = "Title/description substring filter")]
593    pub query: Option<String>,
594    #[arg(long = "label", help = "Require label on candidates (repeatable)")]
595    pub filter_labels: Vec<String>,
596    #[arg(long, help = "Include completed/canceled issues in the candidate list")]
597    pub include_completed: bool,
598    #[arg(
599        long,
600        default_value = "updated",
601        help = "Sort: updated|created|priority|identifier|title|state"
602    )]
603    pub sort: String,
604    #[arg(long, help = "Sort ascending")]
605    pub asc: bool,
606    #[arg(long, default_value_t = 100, help = "Max candidate issues to fetch")]
607    pub limit: usize,
608    #[arg(long, help = "Bulk-assign to this user (`me`/`none`/name/email/id)")]
609    pub assign: Option<String>,
610    #[arg(long = "set-state", help = "Bulk set workflow state name/type/id")]
611    pub set_state: Option<String>,
612    #[arg(
613        long = "add-label",
614        help = "Label to add to all selected issues (repeatable)"
615    )]
616    pub add_labels: Vec<String>,
617    #[arg(long, help = "Project name/id (`none` to clear)")]
618    pub project: Option<String>,
619    #[arg(long, help = "Cycle name/number/id (`none` to clear; one team only)")]
620    pub cycle: Option<String>,
621    #[arg(long, help = "Priority 0–4 or none|urgent|high|medium|low")]
622    pub priority: Option<String>,
623    #[arg(long, short = 'y', help = "Skip confirmation")]
624    pub yes: bool,
625    #[arg(long, help = "Show plan only; do not write")]
626    pub dry_run: bool,
627}
628
629#[cfg(feature = "linear")]
630#[derive(Args, Debug)]
631pub struct LinearCommentCmd {
632    #[arg(help = "Issue id or identifier")]
633    pub id: String,
634    #[arg(long, short = 'm', help = "Comment body (markdown)")]
635    pub body: Option<String>,
636}
637
638#[cfg(feature = "linear")]
639#[derive(Args, Debug)]
640pub struct LinearCommentsCmd {
641    #[arg(help = "Issue id or identifier")]
642    pub id: String,
643}
644
645// ---------------------------------------------------------------------------
646// GitHub issues (`xbp github`)
647// ---------------------------------------------------------------------------
648
649#[derive(Args, Debug)]
650pub struct GithubIssuesCmd {
651    #[arg(long, help = "Repository owner (default: git origin)")]
652    pub owner: Option<String>,
653    #[arg(long, help = "Repository name (default: git origin)")]
654    pub repo: Option<String>,
655    #[command(subcommand)]
656    pub command: Option<GithubSubCommand>,
657}
658
659/// Alias used by `github_cmd` handlers.
660pub type GithubCmd = GithubIssuesCmd;
661
662#[derive(Subcommand, Debug)]
663pub enum GithubSubCommand {
664    #[command(about = "List issues")]
665    List(GithubListCmd),
666    #[command(about = "Show a single issue")]
667    Show(GithubShowCmd),
668    #[command(about = "Create an issue")]
669    Create(GithubCreateCmd),
670    #[command(about = "Edit title/body/labels")]
671    Edit(GithubEditCmd),
672    #[command(about = "Open or close an issue")]
673    Status(GithubStatusCmd),
674    #[command(about = "Set labels")]
675    Labels(GithubLabelsCmd),
676    #[command(about = "Assign or unassign")]
677    Assign(GithubAssignCmd),
678    #[command(about = "Post a comment")]
679    Comment(GithubCommentCmd),
680    #[command(about = "List comments")]
681    Comments(GithubCommentsCmd),
682}
683
684#[derive(Args, Debug)]
685pub struct GithubListCmd {
686    #[arg(long, default_value = "open", help = "open | closed | all")]
687    pub state: String,
688    #[arg(long = "label", help = "Label filter (repeatable)")]
689    pub labels: Vec<String>,
690    #[arg(long, help = "Assignee login or `none`")]
691    pub assignee: Option<String>,
692    #[arg(long, short = 'q', help = "Client-side title/body filter")]
693    pub query: Option<String>,
694    #[arg(long, default_value_t = 50)]
695    pub limit: usize,
696}
697
698#[derive(Args, Debug)]
699pub struct GithubShowCmd {
700    #[arg(help = "Issue number or URL")]
701    pub id: String,
702}
703
704#[derive(Args, Debug)]
705pub struct GithubCreateCmd {
706    #[arg(long)]
707    pub title: Option<String>,
708    #[arg(long)]
709    pub body: Option<String>,
710    #[arg(long = "label")]
711    pub labels: Vec<String>,
712    #[arg(long = "assignee")]
713    pub assignees: Vec<String>,
714    #[arg(long, help = "Prompt for body when interactive")]
715    pub interactive_body: bool,
716}
717
718#[derive(Args, Debug)]
719pub struct GithubEditCmd {
720    #[arg(help = "Issue number")]
721    pub id: String,
722    #[arg(long)]
723    pub title: Option<String>,
724    #[arg(long)]
725    pub body: Option<String>,
726    #[arg(long = "label")]
727    pub labels: Vec<String>,
728}
729
730#[derive(Args, Debug)]
731pub struct GithubStatusCmd {
732    #[arg(help = "Issue number")]
733    pub id: String,
734    #[arg(long, help = "open or closed")]
735    pub state: Option<String>,
736}
737
738#[derive(Args, Debug)]
739pub struct GithubLabelsCmd {
740    #[arg(help = "Issue number")]
741    pub id: String,
742    #[arg(long)]
743    pub add: Vec<String>,
744    #[arg(long)]
745    pub remove: Vec<String>,
746    #[arg(long)]
747    pub set: Vec<String>,
748    #[arg(long)]
749    pub clear: bool,
750}
751
752#[derive(Args, Debug)]
753pub struct GithubAssignCmd {
754    #[arg(help = "Issue number")]
755    pub id: String,
756    #[arg(long, help = "GitHub login, or `none` to unassign")]
757    pub assignee: Option<String>,
758}
759
760#[derive(Args, Debug)]
761pub struct GithubCommentCmd {
762    #[arg(help = "Issue number")]
763    pub id: String,
764    #[arg(long, short = 'm')]
765    pub body: Option<String>,
766}
767
768#[derive(Args, Debug)]
769pub struct GithubCommentsCmd {
770    #[arg(help = "Issue number")]
771    pub id: String,
772}
773
774// ---------------------------------------------------------------------------
775// Issue marker automation (`xbp issues`, deprecated alias `xbp todos`)
776// ---------------------------------------------------------------------------
777
778#[derive(Args, Debug)]
779pub struct TodosCmd {
780    #[command(subcommand)]
781    pub command: Option<TodosSubCommand>,
782}
783
784#[derive(Subcommand, Debug)]
785pub enum TodosSubCommand {
786    #[command(about = "Scan the codebase for TODO/FIXME markers (default)")]
787    Scan(TodosScanCmd),
788    #[command(about = "Idempotently create Linear and/or GitHub issues from code markers")]
789    Sync(TodosSyncCmd),
790    #[command(about = "Show per-marker linkage table + ledger summary")]
791    Status(TodosStatusCmd),
792    #[command(about = "Remove ledger entries for markers no longer present in code")]
793    Prune(TodosPruneCmd),
794    #[command(
795        about = "Measure coding time per linked issue from worktree-watch edits/commits on tracked paths"
796    )]
797    Effort(TodosEffortCmd),
798    #[command(
799        about = "Mark ledger issues done when Linear/GitHub report completed/closed (stops time accumulation)"
800    )]
801    Reconcile(TodosReconcileCmd),
802    #[command(about = "Search Linear and/or GitHub issues")]
803    Search(IssueSearchCmd),
804    #[command(about = "Interactive wizard: write issues automation into .xbp/xbp.yaml")]
805    Setup,
806}
807
808#[derive(Args, Debug)]
809pub struct IssueSearchCmd {
810    #[arg(help = "Title/body/identifier substring")]
811    pub query: String,
812    #[arg(long, help = "Search GitHub issues")]
813    pub github: bool,
814    #[cfg(feature = "linear")]
815    #[arg(long, help = "Search Linear issues")]
816    pub linear: bool,
817    #[arg(long, help = "GitHub owner override")]
818    pub owner: Option<String>,
819    #[arg(long, help = "GitHub repo override")]
820    pub repo: Option<String>,
821    #[arg(long, help = "State filter")]
822    pub state: Option<String>,
823    #[arg(long = "label", help = "Require label name (repeatable; client-side)")]
824    pub labels: Vec<String>,
825    #[arg(long, help = "Assignee filter (`me` supported for Linear)")]
826    pub assignee: Option<String>,
827    #[arg(long, help = "Include completed/canceled/closed issues")]
828    pub include_completed: bool,
829    #[arg(
830        long,
831        default_value = "updated",
832        help = "Linear sort: updated|created|priority|identifier|title|state"
833    )]
834    pub sort: String,
835    #[arg(long, help = "Sort ascending")]
836    pub asc: bool,
837    #[arg(long, default_value_t = 50, help = "Max issues per provider")]
838    pub limit: usize,
839    #[cfg(feature = "linear")]
840    #[arg(long, help = "Open Linear results in the Linear TUI")]
841    pub tui: bool,
842}
843
844#[derive(Args, Debug)]
845pub struct TodosScanCmd {
846    #[arg(long, help = "Root path to scan (default: project/git root)")]
847    pub path: Option<PathBuf>,
848    #[arg(long, help = "Emit JSON")]
849    pub json: bool,
850    #[arg(
851        long,
852        help = "Do not offer interactive sync after scan (also set issues.prompt_sync_after_scan: false)"
853    )]
854    pub no_prompt: bool,
855}
856
857#[derive(Args, Debug)]
858pub struct TodosSyncCmd {
859    #[arg(
860        long,
861        value_enum,
862        help = "Where to create issues (default: issues.default_to from config, else both)"
863    )]
864    pub to: Option<TodosSyncTarget>,
865    #[arg(long, help = "Root path to scan")]
866    pub path: Option<PathBuf>,
867    #[arg(long, help = "Preview creates without writing")]
868    pub dry_run: bool,
869    #[arg(
870        long,
871        short = 'y',
872        help = "Skip interactive multi-select; file all new markers (or set issues.auto_yes: true)"
873    )]
874    pub yes: bool,
875    #[cfg(feature = "linear")]
876    #[arg(long, help = "Linear team key/name/id (requires --features linear)")]
877    pub team: Option<String>,
878    #[arg(long, help = "GitHub owner override")]
879    pub owner: Option<String>,
880    #[arg(long, help = "GitHub repo override")]
881    pub repo: Option<String>,
882    #[arg(
883        long,
884        help = "Stamp source marker lines with created issue IDs (or set issues.annotate_source: true)"
885    )]
886    pub annotate: bool,
887    #[arg(
888        long,
889        help = "Do not stamp source lines even if config enables annotate_source"
890    )]
891    pub no_annotate: bool,
892    #[arg(
893        long,
894        help = "Opt-in: enrich issue title/body with OpenRouter (overrides issues.openrouter_enrich: false)"
895    )]
896    pub enrich: bool,
897    #[arg(
898        long,
899        help = "Disable OpenRouter enrichment even if todos.openrouter_enrich is true"
900    )]
901    pub no_enrich: bool,
902}
903
904#[derive(Args, Debug)]
905pub struct TodosStatusCmd {
906    #[arg(long)]
907    pub path: Option<PathBuf>,
908}
909
910#[derive(Args, Debug)]
911pub struct TodosPruneCmd {
912    #[arg(long)]
913    pub path: Option<PathBuf>,
914    #[arg(long, help = "List stale entries without removing them")]
915    pub dry_run: bool,
916}
917
918#[derive(Args, Debug)]
919pub struct TodosEffortCmd {
920    #[arg(long, help = "Project root (default: .xbp / git root)")]
921    pub path: Option<PathBuf>,
922    #[arg(
923        long,
924        default_value_t = 15,
925        help = "Max minutes between file mutations counted as one coding session (worktree-watch gap)"
926    )]
927    pub gap_minutes: u64,
928    #[arg(long, help = "Emit JSON report")]
929    pub json: bool,
930    #[arg(long, help = "Do not write ledger/effort snapshot")]
931    pub no_persist: bool,
932}
933
934#[derive(Args, Debug)]
935pub struct TodosReconcileCmd {
936    #[arg(long, help = "Project root (default: .xbp / git root)")]
937    pub path: Option<PathBuf>,
938    #[arg(long, help = "Detect closed issues without writing the ledger")]
939    pub dry_run: bool,
940}
941
942#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
943pub enum TodosSyncTarget {
944    #[cfg(feature = "linear")]
945    Linear,
946    Github,
947    #[cfg(feature = "linear")]
948    Both,
949}
950
951#[derive(Args, Debug)]
952#[command(help_template = crate::cli::help_render::XBP_HELP_TEMPLATE)]
953pub struct DeployCmd {
954    #[arg(help = "Service name, deploy.groups name, or `all`")]
955    pub target: String,
956    #[arg(long, help = "Deploy environment (default: deploy.default_env or production)")]
957    pub env: Option<String>,
958    #[arg(long, help = "Print plan only (no mutations). Default when no other mode is set.")]
959    pub plan: bool,
960    #[arg(long, help = "Apply deploy providers for the resolved services")]
961    pub run: bool,
962    #[arg(long, help = "Read-only verification (rollout/health/image checks)")]
963    pub verify: bool,
964    #[arg(long, help = "Status snapshot for resolved services")]
965    pub status: bool,
966    #[arg(
967        long,
968        value_name = "STAGE",
969        help = "Promote stage label and run apply path (e.g. stable)"
970    )]
971    pub promote: Option<String>,
972    #[arg(long, help = "List recent deploy history records")]
973    pub history: bool,
974    #[arg(long, help = "Skip kubernetes context confirmation prompts")]
975    pub yes: bool,
976    #[arg(long, help = "Emit plan JSON (with --plan)")]
977    pub json: bool,
978    #[arg(long, default_value_t = 20, help = "History entries to show with --history")]
979    pub limit: usize,
980}
981
982#[derive(Args, Debug)]
983#[command(
984    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
985    after_help = crate::cli::help_render::COMMIT_AFTER_HELP
986)]
987pub struct CommitCmd {
988    #[arg(
989        long,
990        help = "Generate and print the conventional commit message without creating a git commit"
991    )]
992    pub dry_run: bool,
993    #[arg(
994        short = 'p',
995        long,
996        help = "Push after committing, or push pending local commits when nothing new needs committing"
997    )]
998    pub push: bool,
999    #[arg(long, help = "Skip OpenRouter and use local heuristics only")]
1000    pub no_ai: bool,
1001    #[arg(
1002        long,
1003        help = "OpenRouter model override used for commit generation; otherwise XBP uses the global config default"
1004    )]
1005    pub model: Option<String>,
1006    #[arg(
1007        long,
1008        help = "Force the conventional commit scope (for example: cli, api, docs)"
1009    )]
1010    pub scope: Option<String>,
1011}
1012
1013#[derive(Args, Debug)]
1014#[command(help_template = crate::cli::help_render::XBP_HELP_TEMPLATE)]
1015pub struct PortsCmd {
1016    #[arg(short = 'p', long = "port")]
1017    pub port: Option<u16>,
1018    #[arg(
1019        long = "sort",
1020        default_value = "port",
1021        value_parser = ["port", "pid"],
1022        help = "Sort port rows by port or pid (default: port)"
1023    )]
1024    pub sort: String,
1025    #[arg(long = "kill")]
1026    pub kill: bool,
1027    #[arg(short = 'n', long = "nginx")]
1028    pub nginx: bool,
1029    #[arg(
1030        long = "full",
1031        help = "Show one unified ports view (reconciled listeners + exposure + security flags)"
1032    )]
1033    pub full: bool,
1034    #[arg(
1035        long = "no-local",
1036        help = "Exclude connections where LocalAddr equals RemoteAddr"
1037    )]
1038    pub no_local: bool,
1039    #[arg(
1040        long = "exposure",
1041        help = "Diagnose external exposure per port (binding + firewall layer)"
1042    )]
1043    pub exposure: bool,
1044}
1045
1046#[derive(Args, Debug)]
1047#[command(
1048    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1049    after_help = crate::cli::help_render::CONFIG_AFTER_HELP
1050)]
1051pub struct ConfigCmd {
1052    #[arg(
1053        long,
1054        help = "Show the current project config instead of opening global XBP paths"
1055    )]
1056    pub project: bool,
1057    #[arg(long, help = "Print global XBP paths without opening them")]
1058    pub no_open: bool,
1059    #[command(subcommand)]
1060    pub provider: Option<ConfigProviderCmd>,
1061}
1062
1063#[derive(ValueEnum, Clone, Debug)]
1064pub enum ConfigFileFormat {
1065    Json,
1066    Jsonc,
1067    Toml,
1068    Yaml,
1069}
1070
1071#[derive(Args, Debug)]
1072pub struct MigrateConfigFileCmd {
1073    #[arg(value_enum, help = "Destination format")]
1074    pub format: ConfigFileFormat,
1075    #[arg(
1076        long,
1077        help = "Source XBP config path; defaults to the repository-bound config"
1078    )]
1079    pub from: Option<PathBuf>,
1080    #[arg(long, help = "Destination path; defaults to .xbp/xbp.<format>")]
1081    pub output: Option<PathBuf>,
1082}
1083
1084#[derive(Subcommand, Debug)]
1085pub enum ConfigProviderCmd {
1086    #[command(
1087        name = "migrate-config-file",
1088        about = "Convert the repository-bound XBP config to JSON, JSONC, TOML, or YAML"
1089    )]
1090    MigrateConfigFile(MigrateConfigFileCmd),
1091    #[command(about = "Manage the OpenRouter API key used by AI-enabled commands")]
1092    Openrouter(ConfigSecretCmd),
1093    #[command(about = "Manage the GitHub OAuth2 token used for release automation")]
1094    Github(ConfigSecretCmd),
1095    #[command(
1096        about = "Manage Cloudflare API credentials used by secrets, DNS, and domains (run without a subcommand for the interactive setup wizard)"
1097    )]
1098    Cloudflare(CloudflareConfigCmd),
1099    #[cfg(feature = "linear")]
1100    #[command(
1101        about = "Manage the Linear API key used for release-note issue linking and initiative publishing (feature-gated: linear)"
1102    )]
1103    Linear(LinearConfigCmd),
1104    #[command(about = "Manage npm registry auth and guided npm publish config")]
1105    Npm(RegistryConfigCmd),
1106    #[command(about = "Manage crates.io auth and guided crate publish config")]
1107    Crates(CratesConfigCmd),
1108    #[command(about = "Manage guided release config in .xbp/xbp.yaml")]
1109    Release(ReleaseConfigCmd),
1110    #[command(
1111        about = "Manage Discord webhook notifications for releases, registry publishes, cargo-dist, Linear posts, and OCI deploys (run without a subcommand for the setup wizard)"
1112    )]
1113    Discord(DiscordConfigCmd),
1114    #[command(
1115        about = "Interactively manage static OpenAPI generation in .xbp/xbp.yaml (run without a subcommand for the setup wizard)"
1116    )]
1117    Openapi(OpenapiConfigCmd),
1118}
1119
1120#[derive(Args, Debug)]
1121pub struct ConfigSecretCmd {
1122    #[command(subcommand)]
1123    pub action: ConfigSecretAction,
1124}
1125
1126#[derive(Subcommand, Debug)]
1127pub enum ConfigSecretAction {
1128    #[command(about = "Set provider key (omit value to enter it securely)")]
1129    SetKey {
1130        #[arg(help = "Provider key/token value")]
1131        key: Option<String>,
1132    },
1133    #[command(about = "Delete the stored provider key")]
1134    DeleteKey,
1135    #[command(about = "Show whether a key is configured (masked by default)")]
1136    Show {
1137        #[arg(long, help = "Print full key/token value (not masked)")]
1138        raw: bool,
1139    },
1140}
1141
1142#[derive(Args, Debug)]
1143pub struct CloudflareConfigCmd {
1144    #[command(subcommand)]
1145    pub action: Option<CloudflareConfigAction>,
1146}
1147
1148#[derive(Subcommand, Debug)]
1149pub enum CloudflareConfigAction {
1150    #[command(about = "Set Cloudflare API token (omit value to enter it securely)")]
1151    SetKey {
1152        #[arg(help = "Cloudflare API token")]
1153        key: Option<String>,
1154    },
1155    #[command(about = "Delete the stored Cloudflare API token")]
1156    DeleteKey,
1157    #[command(about = "Show whether a Cloudflare API token is configured")]
1158    ShowKey {
1159        #[arg(long, help = "Print full token value (not masked)")]
1160        raw: bool,
1161    },
1162    #[command(about = "Set the default Cloudflare account ID")]
1163    SetAccountId {
1164        #[arg(help = "Cloudflare account ID")]
1165        account_id: Option<String>,
1166    },
1167    #[command(about = "Delete the stored default Cloudflare account ID")]
1168    DeleteAccountId,
1169    #[command(about = "Show whether a Cloudflare account ID is configured")]
1170    ShowAccountId {
1171        #[arg(long, help = "Print full account ID value (not masked)")]
1172        raw: bool,
1173    },
1174    #[command(about = "Interactive dashboard OAuth linking flow for Cloudflare credentials")]
1175    Login,
1176    #[command(about = "Show Cloudflare credential sources and readiness")]
1177    Status,
1178    #[command(about = "Run the interactive Cloudflare credential setup wizard")]
1179    Setup,
1180}
1181
1182#[cfg(feature = "linear")]
1183#[derive(Args, Debug)]
1184pub struct LinearConfigCmd {
1185    #[command(subcommand)]
1186    pub action: LinearConfigAction,
1187}
1188
1189#[cfg(feature = "linear")]
1190#[derive(Subcommand, Debug)]
1191pub enum LinearConfigAction {
1192    #[command(about = "Set Linear API key (omit value to enter it securely)")]
1193    SetKey {
1194        #[arg(help = "Linear API key/token value")]
1195        key: Option<String>,
1196    },
1197    #[command(about = "Delete the stored Linear API key")]
1198    DeleteKey,
1199    #[command(about = "Show whether a Linear API key is configured (masked by default)")]
1200    Show {
1201        #[arg(long, help = "Print full key/token value (not masked)")]
1202        raw: bool,
1203    },
1204    #[command(
1205        name = "select-initiative",
1206        about = "Pick a Linear initiative for the current repo and save it to .xbp/xbp.yaml"
1207    )]
1208    SelectInitiative,
1209}
1210
1211#[derive(Args, Debug)]
1212pub struct RegistryConfigCmd {
1213    #[command(subcommand)]
1214    pub action: RegistryConfigAction,
1215}
1216
1217#[derive(Args, Debug)]
1218pub struct CratesConfigCmd {
1219    #[command(subcommand)]
1220    pub action: CratesConfigAction,
1221}
1222
1223#[derive(Args, Debug)]
1224pub struct ReleaseConfigCmd {
1225    #[command(subcommand)]
1226    pub action: ReleaseConfigAction,
1227}
1228
1229#[derive(Args, Debug)]
1230pub struct DiscordConfigCmd {
1231    #[command(subcommand)]
1232    pub action: Option<DiscordConfigAction>,
1233}
1234
1235#[derive(Subcommand, Debug)]
1236pub enum DiscordConfigAction {
1237    #[command(about = "Interactive Discord webhook + per-event setup (project and/or global)")]
1238    Setup,
1239    #[command(about = "Set Discord webhook URL (omit value to enter it securely)")]
1240    SetUrl {
1241        #[arg(help = "Discord webhook URL or ${DISCORD_WEBHOOK_URL} placeholder")]
1242        url: Option<String>,
1243        #[arg(
1244            long,
1245            help = "Write to global ~/.xbp/config.yaml instead of the project .xbp/xbp.yaml"
1246        )]
1247        global: bool,
1248    },
1249    #[command(about = "Delete the configured Discord webhook URL")]
1250    DeleteUrl {
1251        #[arg(long, help = "Remove from global config instead of project config")]
1252        global: bool,
1253    },
1254    #[command(about = "Show Discord webhook and per-event notification settings")]
1255    Show {
1256        #[arg(long, help = "Print full webhook URL (not masked)")]
1257        raw: bool,
1258    },
1259    #[command(about = "Interactively enable/disable individual notification events")]
1260    Events {
1261        #[arg(long, help = "Edit global event defaults instead of project config")]
1262        global: bool,
1263    },
1264    #[command(
1265        about = "Interactively enable/disable Discord OCI (and other) events per service in .xbp/xbp.yaml"
1266    )]
1267    Services,
1268    #[command(about = "Send a test Discord webhook embed using the resolved config")]
1269    Test,
1270}
1271
1272#[derive(Args, Debug)]
1273pub struct OpenapiConfigCmd {
1274    #[command(subcommand)]
1275    pub action: Option<OpenapiConfigAction>,
1276}
1277
1278#[derive(Subcommand, Debug)]
1279pub enum OpenapiConfigAction {
1280    #[command(about = "Run the interactive OpenAPI setup wizard for .xbp/xbp.yaml")]
1281    Setup,
1282    #[command(about = "Show project and per-service OpenAPI generation settings")]
1283    Show,
1284}
1285
1286#[derive(Subcommand, Debug)]
1287pub enum RegistryConfigAction {
1288    #[command(about = "Set registry token/key (omit value to enter it securely)")]
1289    SetKey {
1290        #[arg(help = "Registry token value")]
1291        key: Option<String>,
1292    },
1293    #[command(about = "Delete the stored registry token")]
1294    DeleteKey,
1295    #[command(about = "Show whether a registry token is configured (masked by default)")]
1296    Show {
1297        #[arg(long, help = "Print full token value (not masked)")]
1298        raw: bool,
1299    },
1300    #[command(
1301        name = "setup-release",
1302        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
1303    )]
1304    SetupRelease,
1305}
1306
1307#[derive(Subcommand, Debug)]
1308pub enum CratesConfigAction {
1309    #[command(about = "Set crates.io token (omit value to enter it securely)")]
1310    SetKey {
1311        #[arg(help = "crates.io token value")]
1312        key: Option<String>,
1313    },
1314    #[command(about = "Delete the stored crates.io token from global XBP config")]
1315    DeleteKey,
1316    #[command(about = "Show whether a crates.io token is configured (masked by default)")]
1317    Show {
1318        #[arg(long, help = "Print full token value (not masked)")]
1319        raw: bool,
1320    },
1321    #[command(
1322        name = "setup-release",
1323        about = "Interactively configure project publish settings in .xbp/xbp.yaml"
1324    )]
1325    SetupRelease,
1326    #[command(
1327        about = "Run `cargo login` using the stored crates token and sync Cargo's local credentials file"
1328    )]
1329    Login {
1330        #[arg(help = "Optional crates.io token value to save before logging in")]
1331        key: Option<String>,
1332    },
1333    #[command(
1334        about = "Run `cargo logout` to remove Cargo's local crates.io credentials while keeping XBP's stored token"
1335    )]
1336    Logout,
1337}
1338
1339#[derive(Subcommand, Debug)]
1340pub enum ReleaseConfigAction {
1341    #[command(
1342        name = "setup",
1343        about = "Interactively configure release tag naming in .xbp/xbp.yaml"
1344    )]
1345    Setup,
1346}
1347
1348#[derive(Args, Debug)]
1349#[command(help_template = crate::cli::help_render::XBP_HELP_TEMPLATE)]
1350pub struct CurlCmd {
1351    #[arg(help = "URL or domain to fetch, e.g. example.com or https://example.com/api")]
1352    pub url: Option<String>,
1353    #[arg(long, help = "Disable the default 15 second timeout")]
1354    pub no_timeout: bool,
1355}
1356
1357#[derive(Args, Debug)]
1358#[command(
1359    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1360    after_help = crate::cli::help_render::LOGIN_AFTER_HELP
1361)]
1362pub struct LoginCmd {
1363    #[command(subcommand)]
1364    pub action: Option<LoginSubCommand>,
1365}
1366
1367#[derive(Subcommand, Debug)]
1368pub enum LoginSubCommand {
1369    #[command(about = "Show whether the current CLI session is still valid")]
1370    Status,
1371    #[command(about = "Revoke the current CLI token and clear local login state")]
1372    Logout,
1373}
1374
1375#[derive(Args, Debug)]
1376#[command(help_template = crate::cli::help_render::XBP_HELP_TEMPLATE)]
1377pub struct UpdateCmd {
1378    #[arg(
1379        long = "crate",
1380        default_value = "xbp",
1381        help = "crates.io package name to check (default: xbp)"
1382    )]
1383    pub crate_name: String,
1384    #[arg(long, help = "Print the update check result as JSON")]
1385    pub json: bool,
1386    #[arg(
1387        long,
1388        help = "Run `cargo install <crate> --locked --force` when a newer release is available (defaults to --features all)"
1389    )]
1390    pub install: bool,
1391    #[arg(
1392        long = "features",
1393        value_name = "FEATURES",
1394        value_delimiter = ',',
1395        num_args = 1..,
1396        help = "Comma-separated Cargo features for `cargo install` (repeatable). When --install is set and this is omitted, defaults to `all`"
1397    )]
1398    pub features: Vec<String>,
1399    #[arg(
1400        long = "no-default-features",
1401        help = "Pass --no-default-features to `cargo install` (only with --install)"
1402    )]
1403    pub no_default_features: bool,
1404    #[arg(
1405        long = "all-features",
1406        help = "Pass --all-features to `cargo install` (only with --install; overrides --features)"
1407    )]
1408    pub all_features: bool,
1409    #[arg(
1410        long,
1411        help = "Exit with an error when the installed CLI is older than crates.io"
1412    )]
1413    pub fail_if_outdated: bool,
1414}
1415
1416#[derive(Args, Debug)]
1417#[command(
1418    subcommand_precedence_over_arg = true,
1419    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1420    after_help = crate::cli::help_render::VERSION_AFTER_HELP
1421)]
1422pub struct VersionCmd {
1423    #[arg(
1424        short = 'p',
1425        long,
1426        help = "Push after auto-committing version file updates"
1427    )]
1428    pub push: bool,
1429    #[arg(
1430        help = "Show versions, bump with major/minor/patch, or set an explicit version like 1.2.3"
1431    )]
1432    pub target: Option<String>,
1433    #[arg(
1434        short = 'v',
1435        long = "version",
1436        help = "Explicit version target; equivalent to the positional version value and overrides it when both are provided"
1437    )]
1438    pub explicit_version: Option<String>,
1439    #[arg(long, help = "Show normalized git tags from `git tag --list`")]
1440    pub git: bool,
1441    #[command(subcommand)]
1442    pub command: Option<VersionSubCommand>,
1443}
1444
1445#[derive(Subcommand, Debug)]
1446pub enum VersionSubCommand {
1447    #[command(
1448        about = "Create and push a git tag for this version, then create a GitHub release",
1449        visible_alias = "r"
1450    )]
1451    Release(VersionReleaseCmd),
1452    #[command(
1453        about = "Manage Rust workspace release/version drift, sync, validation, and publish flow",
1454        arg_required_else_help = true
1455    )]
1456    Workspace(VersionWorkspaceCmd),
1457    #[command(
1458        about = "Manage explicit release-domain ownership, validation, sync, diagnosis, and guarded releases",
1459        arg_required_else_help = true
1460    )]
1461    Domain(VersionDomainCmd),
1462    /// Discover package roots (Cargo.toml, package.json, etc.) and register them as services
1463    #[command(name = "discover", alias = "register", alias = "register-services")]
1464    Discover(VersionDiscoverServicesCmd),
1465    #[command(
1466        about = "Bump versions only for packages with uncommitted changes in the working tree"
1467    )]
1468    Bump(VersionBumpCmd),
1469}
1470
1471#[derive(Args, Debug)]
1472pub struct VersionBumpCmd {
1473    #[arg(
1474        short = 'p',
1475        long,
1476        help = "Push after auto-committing bumped version files"
1477    )]
1478    pub push: bool,
1479    #[arg(long, help = "Preview bump selections without writing version files")]
1480    pub dry_run: bool,
1481    #[arg(
1482        long,
1483        group = "bump_kind",
1484        help = "Default bump kind for --all and interactive default selection"
1485    )]
1486    pub major: bool,
1487    #[arg(
1488        long,
1489        group = "bump_kind",
1490        help = "Default bump kind for --all and interactive default"
1491    )]
1492    pub minor: bool,
1493    #[arg(
1494        long,
1495        group = "bump_kind",
1496        help = "Default bump kind for --all and interactive default"
1497    )]
1498    pub patch: bool,
1499    #[arg(
1500        long,
1501        help = "Bump every mutated package with the selected default kind without prompting"
1502    )]
1503    pub all: bool,
1504}
1505
1506#[derive(Args, Debug)]
1507pub struct VersionDiscoverServicesCmd {
1508    #[arg(
1509        long,
1510        help = "Preview discovered nested XBP services without writing .xbp/xbp.yaml"
1511    )]
1512    pub dry_run: bool,
1513    #[arg(
1514        long,
1515        help = "Discover nested services only; do not register them in the root config (opt out)"
1516    )]
1517    pub no_register: bool,
1518}
1519
1520#[derive(Args, Debug)]
1521pub struct VersionReleaseCmd {
1522    #[arg(
1523        long,
1524        help = "Release this version instead of auto-detecting from tracked files"
1525    )]
1526    pub version: Option<String>,
1527    #[arg(
1528        long = "flag",
1529        value_enum,
1530        help = "Append build metadata to the release version: dev, stable, beta, alpha, nightly, or exp"
1531    )]
1532    pub flag: Option<VersionReleaseFlag>,
1533    #[arg(
1534        long,
1535        help = "Allow releasing with uncommitted changes in the working tree"
1536    )]
1537    pub allow_dirty: bool,
1538    #[arg(
1539        long,
1540        help = "Release title (defaults to <version>[-<flag>]-<service>)"
1541    )]
1542    pub title: Option<String>,
1543    #[arg(long, help = "Release notes body (Markdown)")]
1544    pub notes: Option<String>,
1545    #[arg(long, help = "Read release notes body from a file")]
1546    pub notes_file: Option<PathBuf>,
1547    #[arg(long, help = "Create as draft release")]
1548    pub draft: bool,
1549    #[arg(long, help = "Mark release as pre-release")]
1550    pub prerelease: bool,
1551    #[arg(
1552        long,
1553        help = "Run configured npm/crates publish workflows before creating the GitHub release"
1554    )]
1555    pub publish: bool,
1556    #[arg(
1557        long,
1558        requires = "publish",
1559        help = "When `--publish` is enabled: skip configured preflight commands, allow a dirty working tree, and auto-sync workspace crate versions. Version release also does not fail on deploy-only config issues (e.g. duplicate service ports)."
1560    )]
1561    pub force: bool,
1562    #[arg(
1563        long,
1564        help = "Plan the release (and optional package publish) without writing versions, tags, ledgers, or publishing"
1565    )]
1566    pub dry_run: bool,
1567    #[arg(
1568        long,
1569        value_enum,
1570        default_value_t = VersionReleaseLatest::Legacy,
1571        help = "Control GitHub latest flag: true, false, or legacy"
1572    )]
1573    pub make_latest: VersionReleaseLatest,
1574}
1575
1576#[derive(Copy, Clone, Debug, ValueEnum)]
1577pub enum VersionReleaseLatest {
1578    True,
1579    False,
1580    Legacy,
1581}
1582
1583#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)]
1584pub enum VersionReleaseFlag {
1585    Dev,
1586    Stable,
1587    Beta,
1588    Alpha,
1589    Nightly,
1590    Exp,
1591}
1592
1593impl VersionReleaseFlag {
1594    pub fn as_str(self) -> &'static str {
1595        match self {
1596            Self::Dev => "dev",
1597            Self::Stable => "stable",
1598            Self::Beta => "beta",
1599            Self::Alpha => "alpha",
1600            Self::Nightly => "nightly",
1601            Self::Exp => "exp",
1602        }
1603    }
1604}
1605
1606#[derive(Args, Debug)]
1607#[command(
1608    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1609    after_help = crate::cli::help_render::PUBLISH_AFTER_HELP
1610)]
1611pub struct PublishCmd {
1612    #[arg(
1613        long,
1614        help = "Validate and print what would publish without uploading packages"
1615    )]
1616    pub dry_run: bool,
1617    #[arg(
1618        long,
1619        help = "Allow publish workflows to run with a dirty working tree"
1620    )]
1621    pub allow_dirty: bool,
1622    #[arg(long, help = "Skip configured preflight commands and publish anyway")]
1623    pub force: bool,
1624    #[arg(
1625        long,
1626        help = "When publishing a crate workspace member, also publish missing internal prerequisites in dependency order"
1627    )]
1628    pub include_prereqs: bool,
1629    #[arg(long, help = "Limit publishing to one target: npm or crates")]
1630    pub target: Option<String>,
1631    #[arg(
1632        long,
1633        help = "Publish the npm package or crate belonging to this configured service"
1634    )]
1635    pub service: Option<String>,
1636    #[arg(
1637        long,
1638        help = "Limit publishing to the workflow whose manifest matches this path"
1639    )]
1640    pub manifest_path: Option<PathBuf>,
1641}
1642
1643#[derive(Args, Debug)]
1644#[command(
1645    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1646    after_help = "Examples:\n  xbp version workspace check --repo C:/Users/floris/Documents/GitHub/athena\n  xbp version workspace sync --version 3.16.5\n  xbp version workspace sync --version 3.16.5 --write\n  xbp version workspace validate --cargo-check --package-dry-run\n  xbp version workspace publish plan\n  xbp version workspace publish plan --only athena-auth --include-prereqs\n  xbp version workspace publish heal --only xbp --include-prereqs --write\n  xbp version workspace publish run --dry-run\n  xbp version workspace publish run --from athena-s3\n  xbp version workspace publish run --only athena-auth --include-prereqs"
1647)]
1648pub struct VersionWorkspaceCmd {
1649    #[command(subcommand)]
1650    pub command: VersionWorkspaceSubCommand,
1651}
1652
1653#[derive(Args, Debug)]
1654#[command(
1655    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1656    after_help = "Examples:\n  xbp version domain init --name auth --root services/auth --write\n  xbp version domain doctor --domain auth\n  xbp version domain sync --domain auth --check\n  xbp version domain diagnose --domain auth --log build.log\n  xbp version domain release --domain auth --patch --deploy"
1657)]
1658pub struct VersionDomainCmd {
1659    #[command(subcommand)]
1660    pub command: VersionDomainSubCommand,
1661}
1662
1663#[derive(Subcommand, Debug)]
1664pub enum VersionDomainSubCommand {
1665    #[command(about = "Discover or scaffold a version-domain policy")]
1666    Init(VersionDomainInitCmd),
1667    #[command(about = "Validate release-domain ownership and configured surfaces")]
1668    Doctor(VersionDomainDoctorCmd),
1669    #[command(about = "Preview or apply version-domain surface synchronization")]
1670    Sync(VersionDomainSyncCmd),
1671    #[command(about = "Classify build logs for release-domain drift and unsafe alignments")]
1672    Diagnose(VersionDomainDiagnoseCmd),
1673    #[command(about = "Run a guarded domain version bump/set and optional deploy")]
1674    Release(VersionDomainReleaseCmd),
1675}
1676
1677#[derive(Args, Debug)]
1678pub struct VersionDomainInitCmd {
1679    #[arg(long, help = "Release domain name")]
1680    pub name: String,
1681    #[arg(long, help = "Domain root path relative to the XBP project root")]
1682    pub root: PathBuf,
1683    #[arg(long, help = "Persist the generated domain to .xbp/xbp.yaml")]
1684    pub write: bool,
1685}
1686
1687#[derive(Args, Debug)]
1688pub struct VersionDomainDoctorCmd {
1689    #[arg(long = "domain", help = "Release domain name")]
1690    pub domain: String,
1691}
1692
1693#[derive(Args, Debug)]
1694pub struct VersionDomainSyncCmd {
1695    #[arg(long = "domain", help = "Release domain name")]
1696    pub domain: String,
1697    #[arg(
1698        long,
1699        conflicts_with = "write",
1700        help = "Validate only; do not write files"
1701    )]
1702    pub check: bool,
1703    #[arg(long, conflicts_with = "check", help = "Write configured surfaces")]
1704    pub write: bool,
1705}
1706
1707#[derive(Args, Debug)]
1708pub struct VersionDomainDiagnoseCmd {
1709    #[arg(long = "domain", help = "Release domain name")]
1710    pub domain: String,
1711    #[arg(long, help = "Build or deploy log file to classify")]
1712    pub log: Option<PathBuf>,
1713}
1714
1715#[derive(Args, Debug)]
1716#[command(group(
1717    clap::ArgGroup::new("domain_release_version")
1718        .required(true)
1719        .args(["patch", "minor", "major", "version"])
1720))]
1721pub struct VersionDomainReleaseCmd {
1722    #[arg(long = "domain", help = "Release domain name")]
1723    pub domain: String,
1724    #[arg(long, help = "Bump the domain patch version")]
1725    pub patch: bool,
1726    #[arg(long, help = "Bump the domain minor version")]
1727    pub minor: bool,
1728    #[arg(long, help = "Bump the domain major version")]
1729    pub major: bool,
1730    #[arg(long, help = "Set an explicit domain version")]
1731    pub version: Option<String>,
1732    #[arg(long, help = "Run the configured Cloudflare app release after sync")]
1733    pub deploy: bool,
1734    #[arg(long, help = "Allow an otherwise blocked major-version jump")]
1735    pub allow_major_jump: bool,
1736    #[arg(
1737        long,
1738        value_name = "DOMAIN",
1739        help = "Explicitly record an allowed cross-domain version source"
1740    )]
1741    pub allow_cross_domain_version: Option<String>,
1742}
1743
1744#[derive(Args, Debug, Clone, Default)]
1745pub struct VersionWorkspaceTargetArgs {
1746    #[arg(
1747        long,
1748        help = "Workspace repo root to inspect (defaults to current project root)"
1749    )]
1750    pub repo: Option<PathBuf>,
1751    #[arg(long, help = "Emit machine-readable JSON output")]
1752    pub json: bool,
1753}
1754
1755#[derive(Subcommand, Debug)]
1756pub enum VersionWorkspaceSubCommand {
1757    #[command(about = "Detect workspace release drift and exit non-zero when mismatches exist")]
1758    Check(VersionWorkspaceCheckCmd),
1759    #[command(about = "Preview or apply workspace-wide version alignment")]
1760    Sync(VersionWorkspaceSyncCmd),
1761    #[command(about = "Run structural and optional cargo validation for workspace publishability")]
1762    Validate(VersionWorkspaceValidateCmd),
1763    #[command(about = "Plan or execute crates.io publishing for workspace packages")]
1764    Publish(VersionWorkspacePublishCmd),
1765}
1766
1767#[derive(Args, Debug)]
1768pub struct VersionWorkspaceCheckCmd {
1769    #[command(flatten)]
1770    pub target: VersionWorkspaceTargetArgs,
1771    #[arg(
1772        long,
1773        help = "Expected release version (defaults to the root package version)"
1774    )]
1775    pub version: Option<String>,
1776}
1777
1778#[derive(Args, Debug)]
1779pub struct VersionWorkspaceSyncCmd {
1780    #[command(flatten)]
1781    pub target: VersionWorkspaceTargetArgs,
1782    #[arg(
1783        long,
1784        help = "Target release version (defaults to the root package version)"
1785    )]
1786    pub version: Option<String>,
1787    #[arg(
1788        long,
1789        help = "Write changes to disk instead of previewing the sync plan"
1790    )]
1791    pub write: bool,
1792}
1793
1794#[derive(Args, Debug)]
1795pub struct VersionWorkspaceValidateCmd {
1796    #[command(flatten)]
1797    pub target: VersionWorkspaceTargetArgs,
1798    #[arg(long, help = "Limit cargo validation to a single package name")]
1799    pub package: Option<String>,
1800    #[arg(long, help = "Run `cargo check -q` as part of validation")]
1801    pub cargo_check: bool,
1802    #[arg(
1803        long,
1804        help = "Run `cargo publish --dry-run --locked` for publishable packages"
1805    )]
1806    pub package_dry_run: bool,
1807}
1808
1809#[derive(Args, Debug)]
1810#[command(arg_required_else_help = true)]
1811pub struct VersionWorkspacePublishCmd {
1812    #[command(subcommand)]
1813    pub command: VersionWorkspacePublishSubCommand,
1814}
1815
1816#[derive(Subcommand, Debug)]
1817pub enum VersionWorkspacePublishSubCommand {
1818    #[command(about = "Show publish order, crates.io visibility, and blockers without publishing")]
1819    Plan(VersionWorkspacePublishPlanCmd),
1820    #[command(
1821        about = "Inspect or auto-fix Cargo publish surface issues (publish=false, version pins, metadata)"
1822    )]
1823    Heal(VersionWorkspacePublishHealCmd),
1824    #[command(about = "Publish workspace packages in dependency order")]
1825    Run(VersionWorkspacePublishRunCmd),
1826}
1827
1828#[derive(Args, Debug)]
1829pub struct VersionWorkspacePublishHealCmd {
1830    #[command(flatten)]
1831    pub target: VersionWorkspaceTargetArgs,
1832    #[arg(long, help = "Limit healing to the publish closure of one package")]
1833    pub only: Option<String>,
1834    #[arg(
1835        long,
1836        help = "When healing a single package, also include internal path-dependency prerequisites"
1837    )]
1838    pub include_prereqs: bool,
1839    #[arg(long, help = "Write Cargo.toml fixes (default is dry preview)")]
1840    pub write: bool,
1841    #[arg(
1842        long,
1843        help = "Expected workspace version for pin alignment (default: highest package version / config)"
1844    )]
1845    pub version: Option<String>,
1846}
1847
1848#[derive(Args, Debug)]
1849pub struct VersionWorkspacePublishPlanCmd {
1850    #[command(flatten)]
1851    pub target: VersionWorkspaceTargetArgs,
1852    #[arg(long, help = "Limit the plan to one package")]
1853    pub only: Option<String>,
1854    #[arg(
1855        long,
1856        help = "When planning a single package, also include missing internal prerequisites"
1857    )]
1858    pub include_prereqs: bool,
1859}
1860
1861#[derive(Args, Debug)]
1862pub struct VersionWorkspacePublishRunCmd {
1863    #[command(flatten)]
1864    pub target: VersionWorkspaceTargetArgs,
1865    #[arg(long, help = "Preview publish actions without calling cargo publish")]
1866    pub dry_run: bool,
1867    #[arg(
1868        long,
1869        help = "Start publishing from this package in the computed order"
1870    )]
1871    pub from: Option<String>,
1872    #[arg(long, help = "Publish only this package")]
1873    pub only: Option<String>,
1874    #[arg(
1875        long,
1876        help = "When publishing one package, also publish missing internal prerequisites in dependency order"
1877    )]
1878    pub include_prereqs: bool,
1879    #[arg(long, help = "Continue publishing remaining packages after a failure")]
1880    pub continue_on_error: bool,
1881    #[arg(long, help = "Allow publishing from a dirty worktree")]
1882    pub allow_dirty: bool,
1883    #[arg(
1884        long,
1885        default_value_t = true,
1886        action = clap::ArgAction::Set,
1887        help = "Auto-fix publish-surface issues (publish=false, missing version pins) before publishing"
1888    )]
1889    pub auto_fix: bool,
1890    #[arg(
1891        long,
1892        default_value_t = 180.0,
1893        help = "How long to wait for each published version to become visible on crates.io"
1894    )]
1895    pub timeout_seconds: f64,
1896    #[arg(
1897        long,
1898        default_value_t = 5.0,
1899        help = "How often to poll crates.io for the just-published version"
1900    )]
1901    pub poll_interval_seconds: f64,
1902}
1903
1904#[derive(Args, Debug)]
1905pub struct RedeployV2Cmd {
1906    #[arg(short = 'p', long = "password")]
1907    pub password: Option<String>,
1908    #[arg(short = 'u', long = "username")]
1909    pub username: Option<String>,
1910    #[arg(short = 'h', long = "host")]
1911    pub host: Option<String>,
1912    #[arg(short = 'd', long = "project-dir")]
1913    pub project_dir: Option<String>,
1914}
1915
1916#[derive(Args, Debug)]
1917#[command(
1918    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1919    after_help = crate::cli::help_render::LOGS_AFTER_HELP
1920)]
1921pub struct LogsCmd {
1922    #[arg()]
1923    pub project: Option<String>,
1924    #[arg(long = "ssh-host", help = "SSH host to stream logs from")]
1925    pub ssh_host: Option<String>,
1926    #[arg(long = "ssh-username", help = "SSH username for remote host")]
1927    pub ssh_username: Option<String>,
1928    #[arg(long = "ssh-password", help = "SSH password for remote host")]
1929    pub ssh_password: Option<String>,
1930}
1931
1932#[derive(Args, Debug)]
1933#[command(
1934    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
1935    after_help = crate::cli::help_render::SSH_AFTER_HELP
1936)]
1937pub struct SshCmd {
1938    #[arg(long = "host", alias = "ssh-host", help = "SSH host or IP address")]
1939    pub ssh_host: Option<String>,
1940    #[arg(
1941        long = "port",
1942        default_value_t = 22,
1943        help = "SSH port for direct connections"
1944    )]
1945    pub ssh_port: u16,
1946    #[arg(
1947        long = "username",
1948        alias = "ssh-username",
1949        help = "SSH username for the remote host"
1950    )]
1951    pub ssh_username: Option<String>,
1952    #[arg(
1953        long = "password",
1954        alias = "ssh-password",
1955        help = "SSH password (omit to use stored config or a secure prompt)"
1956    )]
1957    pub ssh_password: Option<String>,
1958    #[arg(
1959        long,
1960        help = "Path to a private key file to use instead of password auth"
1961    )]
1962    pub private_key: Option<PathBuf>,
1963    #[arg(long, help = "Passphrase for --private-key when required")]
1964    pub private_key_passphrase: Option<String>,
1965    #[arg(
1966        long,
1967        help = "Run this remote command in a PTY instead of opening the default login shell"
1968    )]
1969    pub command: Option<String>,
1970    #[arg(
1971        long,
1972        help = "TERM value sent to the server (default: TERM env var or xterm-256color)"
1973    )]
1974    pub term: Option<String>,
1975    #[arg(long, help = "Disable SSH host key verification")]
1976    pub no_host_key_check: bool,
1977    #[arg(
1978        long,
1979        help = "Pin the SSH host key as a base64 blob when using tunnels or first-connect flows"
1980    )]
1981    pub host_key: Option<String>,
1982    #[arg(
1983        long,
1984        help = "Path to a known_hosts file used for SSH host verification"
1985    )]
1986    pub known_hosts_file: Option<PathBuf>,
1987    #[arg(
1988        long,
1989        help = "Cloudflare Access hostname used to open a local cloudflared TCP forwarder"
1990    )]
1991    pub cloudflared_hostname: Option<String>,
1992    #[arg(long, help = "Override the cloudflared binary path")]
1993    pub cloudflared_binary: Option<PathBuf>,
1994    #[arg(
1995        long,
1996        help = "Optional destination host:port passed to cloudflared access tcp"
1997    )]
1998    pub cloudflared_destination: Option<String>,
1999}
2000
2001#[derive(Args, Debug)]
2002#[command(
2003    arg_required_else_help = true,
2004    disable_help_subcommand = true,
2005    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE
2006)]
2007pub struct CloudflaredCmd {
2008    #[command(subcommand)]
2009    pub command: CloudflaredSubCommand,
2010}
2011
2012#[derive(Subcommand, Debug)]
2013pub enum CloudflaredSubCommand {
2014    #[command(about = "Start a local cloudflared Access TCP forwarder")]
2015    Tcp(CloudflaredTcpCmd),
2016}
2017
2018#[derive(Args, Debug)]
2019#[command(
2020    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2021    after_help = "Examples:\n  xbp cloudflared tcp --hostname bastion.example.com\n  xbp cloudflared tcp --hostname bastion.example.com --listener 127.0.0.1:2222\n  xbp cloudflared tcp --hostname bastion.example.com --destination ssh.internal:22"
2022)]
2023pub struct CloudflaredTcpCmd {
2024    #[arg(long, help = "Protected Cloudflare Access hostname")]
2025    pub hostname: Option<String>,
2026    #[arg(
2027        long,
2028        help = "Local listener address for the forwarder (default: auto-allocate 127.0.0.1:<port>)"
2029    )]
2030    pub listener: Option<String>,
2031    #[arg(
2032        long,
2033        help = "Optional destination host:port passed to cloudflared access tcp"
2034    )]
2035    pub destination: Option<String>,
2036    #[arg(long, help = "Override the cloudflared binary path")]
2037    pub binary: Option<PathBuf>,
2038}
2039
2040#[derive(Args, Debug)]
2041#[command(
2042    arg_required_else_help = true,
2043    disable_help_subcommand = true,
2044    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2045    after_help = crate::cli::help_render::NGINX_AFTER_HELP
2046)]
2047pub struct NginxCmd {
2048    #[command(subcommand)]
2049    pub command: NginxSubCommand,
2050}
2051
2052#[derive(Args, Debug)]
2053#[command(
2054    arg_required_else_help = true,
2055    disable_help_subcommand = true,
2056    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE
2057)]
2058pub struct NetworkCmd {
2059    #[command(subcommand)]
2060    pub command: NetworkSubCommand,
2061}
2062
2063#[derive(Subcommand, Debug)]
2064pub enum NetworkSubCommand {
2065    #[command(about = "Manage persistent floating IP configuration")]
2066    FloatingIp(NetworkFloatingIpCmd),
2067    #[command(about = "Inspect discovered network configuration sources")]
2068    Config(NetworkConfigCmd),
2069    #[command(about = "Manage Hetzner-specific Linux network configuration")]
2070    Hetzner(NetworkHetznerCmd),
2071}
2072
2073#[derive(Args, Debug)]
2074pub struct NetworkFloatingIpCmd {
2075    #[command(subcommand)]
2076    pub command: NetworkFloatingIpSubCommand,
2077}
2078
2079#[derive(Subcommand, Debug)]
2080pub enum NetworkFloatingIpSubCommand {
2081    #[command(about = "Add a persistent floating IP entry to detected network backend")]
2082    Add {
2083        #[arg(long, help = "Floating IP address (IPv4 or IPv6)")]
2084        ip: String,
2085        #[arg(long, help = "CIDR suffix (defaults: IPv4=32, IPv6=64)")]
2086        cidr: Option<u8>,
2087        #[arg(long, help = "Network interface override (auto-detected when omitted)")]
2088        interface: Option<String>,
2089        #[arg(long, help = "Optional label for backend metadata/file naming")]
2090        label: Option<String>,
2091        #[arg(long, help = "Apply network changes after writing config")]
2092        apply: bool,
2093        #[arg(long, help = "Preview computed changes without writing files")]
2094        dry_run: bool,
2095    },
2096    #[command(about = "List floating IPs from runtime and persisted network config")]
2097    List {
2098        #[arg(long, help = "Emit JSON output")]
2099        json: bool,
2100    },
2101}
2102
2103#[derive(Args, Debug)]
2104pub struct NetworkConfigCmd {
2105    #[command(subcommand)]
2106    pub command: NetworkConfigSubCommand,
2107}
2108
2109#[derive(Subcommand, Debug)]
2110pub enum NetworkConfigSubCommand {
2111    #[command(about = "List detected backend and configuration source files")]
2112    List {
2113        #[arg(long, help = "Emit JSON output")]
2114        json: bool,
2115    },
2116}
2117
2118#[derive(Args, Debug)]
2119pub struct NetworkHetznerCmd {
2120    #[command(subcommand)]
2121    pub command: NetworkHetznerSubCommand,
2122}
2123
2124#[derive(Subcommand, Debug)]
2125pub enum NetworkHetznerSubCommand {
2126    #[command(about = "Configure a Hetzner vSwitch VLAN interface persistently")]
2127    Vswitch(NetworkHetznerVswitchCmd),
2128}
2129
2130#[derive(Args, Debug)]
2131pub struct NetworkHetznerVswitchCmd {
2132    #[command(subcommand)]
2133    pub command: NetworkHetznerVswitchSubCommand,
2134}
2135
2136#[derive(Subcommand, Debug)]
2137pub enum NetworkHetznerVswitchSubCommand {
2138    #[command(about = "Write persistent Linux config for a Hetzner vSwitch VLAN interface")]
2139    Setup {
2140        #[arg(
2141            long,
2142            help = "Private IPv4 address to assign on the vSwitch VLAN interface"
2143        )]
2144        ip: String,
2145        #[arg(
2146            long,
2147            default_value_t = 24,
2148            help = "CIDR prefix for --ip (default: 24)"
2149        )]
2150        cidr: u8,
2151        #[arg(long, help = "Physical parent interface (auto-detected when omitted)")]
2152        interface: Option<String>,
2153        #[arg(long, help = "Hetzner vSwitch VLAN ID")]
2154        vlan_id: u16,
2155        #[arg(long, default_value_t = 1400, help = "Interface MTU (default: 1400)")]
2156        mtu: u16,
2157        #[arg(
2158            long,
2159            default_value = "10.0.3.1",
2160            help = "Gateway for the routed Hetzner cloud network"
2161        )]
2162        gateway: String,
2163        #[arg(
2164            long,
2165            default_value = "10.0.0.0/16",
2166            help = "Destination CIDR routed through the Hetzner vSwitch gateway"
2167        )]
2168        route_cidr: String,
2169        #[arg(long, help = "Apply or activate the new config immediately")]
2170        apply: bool,
2171        #[arg(long, help = "Preview file changes without writing them")]
2172        dry_run: bool,
2173    },
2174}
2175
2176#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
2177pub enum NginxDnsMode {
2178    Manual,
2179    Plugin,
2180}
2181
2182#[derive(Subcommand, Debug)]
2183pub enum NginxSubCommand {
2184    #[command(
2185        about = "Provision an HTTPS NGINX reverse proxy with Certbot",
2186        long_about = "Provision an NGINX reverse proxy, issue or reuse Let's Encrypt certificates,\n\
2187and write final HTTP->HTTPS redirect + TLS proxy config.\n\
2188\n\
2189Wildcard domains (for example *.example.com) require DNS-01 mode.\n\
2190Use --dns-mode manual for interactive TXT record prompts, or --dns-mode plugin\n\
2191with --dns-plugin and --dns-creds for non-interactive provider automation."
2192    )]
2193    Setup {
2194        #[arg(short, long, help = "Domain name (supports wildcard: *.example.com)")]
2195        domain: String,
2196        #[arg(short, long, help = "Port to proxy to")]
2197        port: u16,
2198        #[arg(
2199            short,
2200            long,
2201            help = "Email used for Let's Encrypt account registration"
2202        )]
2203        email: String,
2204        #[arg(
2205            long,
2206            value_enum,
2207            default_value_t = NginxDnsMode::Manual,
2208            help = "DNS challenge mode for wildcard certificates: manual or plugin"
2209        )]
2210        dns_mode: NginxDnsMode,
2211        #[arg(
2212            long,
2213            help = "Certbot DNS plugin name for --dns-mode plugin (for example: cloudflare)"
2214        )]
2215        dns_plugin: Option<String>,
2216        #[arg(
2217            long,
2218            help = "Path to DNS plugin credentials file for --dns-mode plugin"
2219        )]
2220        dns_creds: Option<PathBuf>,
2221        #[arg(
2222            long,
2223            default_value_t = true,
2224            action = clap::ArgAction::Set,
2225            value_parser = clap::builder::BoolishValueParser::new(),
2226            help = "For wildcard domains, also request the base domain certificate (true|false)"
2227        )]
2228        include_base: bool,
2229    },
2230    #[command(about = "List discovered NGINX sites with listen/upstream ports")]
2231    List,
2232    #[command(about = "Show full NGINX config for one domain or all domains")]
2233    Show {
2234        #[arg(help = "Optional domain name to inspect")]
2235        domain: Option<String>,
2236    },
2237    #[command(about = "Open an NGINX site config in your configured editor")]
2238    Edit {
2239        #[arg(help = "Domain name to edit")]
2240        domain: String,
2241    },
2242    #[command(about = "Update upstream port for an existing NGINX site")]
2243    Update {
2244        #[arg(short, long, help = "Domain name to update")]
2245        domain: String,
2246        #[arg(short, long, help = "New port to proxy to")]
2247        port: u16,
2248    },
2249}
2250
2251#[derive(Args, Debug)]
2252#[command(
2253    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2254    after_help = crate::cli::help_render::DIAG_AFTER_HELP
2255)]
2256pub struct DiagCmd {
2257    #[arg(long, help = "Check Nginx configuration")]
2258    pub nginx: bool,
2259    #[arg(long, hide = true)]
2260    pub refresh_system_inventory: bool,
2261    #[arg(
2262        long,
2263        help = "Refresh and print persisted machine inventory in the global XBP config"
2264    )]
2265    pub codetime: bool,
2266    #[arg(
2267        long,
2268        help = "Inspect Cursor roaming data on Windows; implies --codetime"
2269    )]
2270    pub cursor: bool,
2271    #[arg(long, help = "Check specific ports (comma-separated)")]
2272    pub ports: Option<String>,
2273    #[arg(long, help = "Skip internet speed test")]
2274    pub no_speed_test: bool,
2275    #[arg(
2276        long,
2277        help = "Path to docker compose file to validate (defaults to docker-compose.yml/compose.yml)"
2278    )]
2279    pub compose_file: Option<String>,
2280}
2281
2282#[derive(Args, Debug)]
2283pub struct MonitorCmd {
2284    #[command(subcommand)]
2285    pub command: Option<MonitorSubCommand>,
2286}
2287
2288#[derive(Subcommand, Debug)]
2289pub enum MonitorSubCommand {
2290    Check,
2291    Start,
2292}
2293
2294#[cfg(feature = "monitoring")]
2295#[derive(Args, Debug)]
2296pub struct MonitoringCmd {
2297    #[command(subcommand)]
2298    pub command: MonitoringSubCommand,
2299}
2300
2301#[cfg(feature = "monitoring")]
2302#[derive(Subcommand, Debug)]
2303pub enum MonitoringSubCommand {
2304    Serve {
2305        #[arg(
2306            short,
2307            long,
2308            default_value = "prodzilla.yml",
2309            help = "Monitoring config file"
2310        )]
2311        file: String,
2312    },
2313    RunOnce {
2314        #[arg(
2315            short,
2316            long,
2317            default_value = "prodzilla.yml",
2318            help = "Monitoring config file"
2319        )]
2320        file: String,
2321        #[arg(long, help = "Run probes only")]
2322        probes_only: bool,
2323        #[arg(long, help = "Run stories only")]
2324        stories_only: bool,
2325    },
2326    List {
2327        #[arg(
2328            short,
2329            long,
2330            default_value = "prodzilla.yml",
2331            help = "Monitoring config file"
2332        )]
2333        file: String,
2334    },
2335}
2336
2337#[derive(Args, Debug)]
2338#[command(
2339    arg_required_else_help = true,
2340    disable_help_subcommand = true,
2341    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2342    after_help = "Examples:\n  xbp api install --port 8080\n  xbp api health\n  xbp api projects list\n  xbp api daemons list\n  xbp api jobs list --status queued\n  xbp api routes list --base-url http://127.0.0.1:8080\n  xbp api request /api/registry/installers/python-pip --web\n\nUse `--web` to target the hosted xbp.app origin instead of API_XBP_URL."
2343)]
2344pub struct ApiCmd {
2345    #[command(subcommand)]
2346    pub command: ApiSubCommand,
2347}
2348
2349#[derive(Args, Debug)]
2350#[command(
2351    arg_required_else_help = true,
2352    disable_help_subcommand = true,
2353    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2354    after_help = "Examples:\n  xbp runners orgs list\n  xbp runners groups list --organization-id <uuid>\n  xbp runners groups create --organization-id <uuid> --name builders --visibility selected\n  xbp runners groups access <runner-group-id>\n  xbp runners inventory --organization-id <uuid>\n  xbp runners hosts enroll --organization-id <uuid> --platform linux --hostname runner-1 --runner-name-prefix xbp-linux\n  xbp runners hosts preflight --runner-host-id <uuid>\n  xbp runners status --organization-id <uuid>\n  xbp runners deploy --organization-id <uuid> --runner-host-id <uuid>\n  xbp runners agent serve --runner-host-id <uuid>"
2355)]
2356pub struct RunnersCmd {
2357    #[command(subcommand)]
2358    pub command: RunnersSubCommand,
2359}
2360
2361#[derive(Subcommand, Debug)]
2362pub enum RunnersSubCommand {
2363    #[command(about = "List organizations already registered in the XBP control plane")]
2364    OrgsList(RunnersOrgsListCmd),
2365    #[command(about = "Manage runner groups")]
2366    Groups(RunnersGroupsCmd),
2367    #[command(about = "List or enroll runner hosts")]
2368    Hosts(RunnersHostsCmd),
2369    #[command(about = "List runner inventory for an organization")]
2370    Inventory(RunnersInventoryCmd),
2371    #[command(about = "Show runner status for an organization or host")]
2372    Status(RunnersStatusCmd),
2373    #[command(about = "Show recent runner job logs and failures")]
2374    Logs(RunnersLogsCmd),
2375    #[command(about = "Queue a runner sync job")]
2376    Sync(RunnersSyncCmd),
2377    #[command(about = "Queue a runner deployment job")]
2378    Deploy(RunnersDeployCmd),
2379    #[command(about = "Queue a runner update job")]
2380    Update(RunnersUpdateCmd),
2381    #[command(about = "Queue a runner removal job")]
2382    Remove(RunnersRemoveCmd),
2383    #[command(about = "Run a local runner host poller that claims and executes jobs")]
2384    Agent(RunnersAgentCmd),
2385}
2386
2387#[derive(Args, Debug)]
2388pub struct RunnersOrgsListCmd {
2389    #[command(flatten)]
2390    pub target: ApiTargetOptions,
2391}
2392
2393#[derive(Args, Debug)]
2394pub struct RunnersGroupsCmd {
2395    #[command(subcommand)]
2396    pub command: RunnersGroupsSubCommand,
2397}
2398
2399#[derive(Subcommand, Debug)]
2400pub enum RunnersGroupsSubCommand {
2401    #[command(about = "List runner groups for an organization")]
2402    List(RunnersGroupsListCmd),
2403    #[command(about = "Create or upsert a runner group")]
2404    Create(RunnersGroupsCreateCmd),
2405    #[command(about = "Update an existing runner group")]
2406    Update(RunnersGroupsCreateCmd),
2407    #[command(about = "Delete a runner group")]
2408    Delete(RunnersGroupsDeleteCmd),
2409    #[command(about = "Inspect effective repository access for a runner group")]
2410    Access(RunnersGroupsAccessCmd),
2411}
2412
2413#[derive(Args, Debug)]
2414pub struct RunnersGroupsListCmd {
2415    #[arg(long, help = "Organization ID")]
2416    pub organization_id: String,
2417    #[command(flatten)]
2418    pub target: ApiTargetOptions,
2419}
2420
2421#[derive(Args, Debug, Clone)]
2422pub struct RunnersGroupsCreateCmd {
2423    #[arg(long, help = "Organization ID")]
2424    pub organization_id: String,
2425    #[arg(long, help = "Runner group name")]
2426    pub name: String,
2427    #[arg(long, help = "Visibility: all, selected, or private")]
2428    pub visibility: Option<String>,
2429    #[arg(long, help = "Optional GitHub installation ID")]
2430    pub github_installation_id: Option<String>,
2431    #[arg(long, help = "Optional GitHub runner group ID")]
2432    pub github_runner_group_id: Option<i64>,
2433    #[arg(long, help = "Optional sync state")]
2434    pub sync_state: Option<String>,
2435    #[arg(long, help = "Mark group as inherited")]
2436    pub inherited: bool,
2437    #[arg(long, help = "Allow public repositories")]
2438    pub allows_public_repositories: bool,
2439    #[arg(long, help = "Prompt for visibility and repository access settings")]
2440    pub interactive: bool,
2441    #[arg(long, help = "Restrict group to workflows")]
2442    pub restricted_to_workflows: bool,
2443    #[arg(long, help = "Selected workflows JSON array")]
2444    pub selected_workflows_json: Option<String>,
2445    #[arg(long, help = "Metadata JSON object")]
2446    pub metadata_json: Option<String>,
2447    #[command(flatten)]
2448    pub target: ApiTargetOptions,
2449}
2450
2451#[derive(Args, Debug)]
2452pub struct RunnersGroupsDeleteCmd {
2453    #[arg(help = "Runner group ID")]
2454    pub runner_group_id: String,
2455    #[command(flatten)]
2456    pub target: ApiTargetOptions,
2457}
2458
2459#[derive(Args, Debug)]
2460pub struct RunnersGroupsAccessCmd {
2461    #[arg(help = "Runner group ID")]
2462    pub runner_group_id: String,
2463    #[command(flatten)]
2464    pub target: ApiTargetOptions,
2465}
2466
2467#[derive(Args, Debug)]
2468pub struct RunnersHostsCmd {
2469    #[command(subcommand)]
2470    pub command: RunnersHostsSubCommand,
2471}
2472
2473#[derive(Subcommand, Debug)]
2474pub enum RunnersHostsSubCommand {
2475    #[command(about = "List runner hosts")]
2476    List(RunnersHostsListCmd),
2477    #[command(about = "Enroll or upsert a runner host")]
2478    Enroll(Box<RunnersHostsEnrollCmd>),
2479    #[command(about = "Run host preflight checks and persist the result")]
2480    Preflight(RunnersHostsPreflightCmd),
2481    #[command(about = "Inspect host enrollment, heartbeat, and preflight state")]
2482    Status(RunnersHostsStatusCmd),
2483}
2484
2485#[derive(Args, Debug)]
2486pub struct RunnersHostsListCmd {
2487    #[arg(long, help = "Optional organization ID")]
2488    pub organization_id: Option<String>,
2489    #[arg(long, help = "Optional daemon ID")]
2490    pub daemon_id: Option<String>,
2491    #[arg(long, help = "Optional status filter")]
2492    pub status: Option<String>,
2493    #[command(flatten)]
2494    pub target: ApiTargetOptions,
2495}
2496
2497#[derive(Args, Debug)]
2498pub struct RunnersHostsEnrollCmd {
2499    #[arg(long, help = "Organization ID")]
2500    pub organization_id: String,
2501    #[arg(long, help = "Optional daemon ID for daemon-backed hosts")]
2502    pub daemon_id: Option<String>,
2503    #[arg(long, help = "Host kind: daemon or rogue")]
2504    pub host_kind: String,
2505    #[arg(long, help = "Platform: linux, windows, or macos")]
2506    pub platform: String,
2507    #[arg(long, help = "Hostname")]
2508    pub hostname: String,
2509    #[arg(long, help = "Runner name prefix")]
2510    pub runner_name_prefix: String,
2511    #[arg(long, help = "Optional display name")]
2512    pub display_name: Option<String>,
2513    #[arg(long, help = "Optional architecture")]
2514    pub arch: Option<String>,
2515    #[arg(long, help = "Optional status")]
2516    pub status: Option<String>,
2517    #[arg(long, help = "Labels JSON object")]
2518    pub labels_json: Option<String>,
2519    #[arg(long, help = "Capabilities JSON object")]
2520    pub capabilities_json: Option<String>,
2521    #[arg(long, help = "Metadata JSON object")]
2522    pub metadata_json: Option<String>,
2523    #[command(flatten)]
2524    pub target: ApiTargetOptions,
2525}
2526
2527#[derive(Args, Debug)]
2528pub struct RunnersHostsPreflightCmd {
2529    #[arg(long, help = "Runner host ID")]
2530    pub runner_host_id: String,
2531    #[arg(
2532        long,
2533        help = "Persist the preflight result back to the control-plane API"
2534    )]
2535    pub write_api: bool,
2536    #[command(flatten)]
2537    pub target: ApiTargetOptions,
2538}
2539
2540#[derive(Args, Debug)]
2541pub struct RunnersHostsStatusCmd {
2542    #[arg(long, help = "Runner host ID")]
2543    pub runner_host_id: String,
2544    #[command(flatten)]
2545    pub target: ApiTargetOptions,
2546}
2547
2548#[derive(Args, Debug)]
2549pub struct RunnersInventoryCmd {
2550    #[arg(long, help = "Organization ID")]
2551    pub organization_id: String,
2552    #[command(flatten)]
2553    pub target: ApiTargetOptions,
2554}
2555
2556#[derive(Args, Debug)]
2557pub struct RunnersStatusCmd {
2558    #[arg(long, help = "Organization ID")]
2559    pub organization_id: String,
2560    #[arg(long, help = "Optional runner host ID")]
2561    pub runner_host_id: Option<String>,
2562    #[arg(long, help = "Optional runner ID")]
2563    pub runner_id: Option<String>,
2564    #[arg(long, help = "Optional daemon ID")]
2565    pub daemon_id: Option<String>,
2566    #[arg(long, help = "Optional status filter")]
2567    pub status: Option<String>,
2568    #[arg(long, help = "Optional phase filter")]
2569    pub phase: Option<String>,
2570    #[arg(long, default_value_t = 20, help = "Maximum jobs to show")]
2571    pub limit: usize,
2572    #[command(flatten)]
2573    pub target: ApiTargetOptions,
2574}
2575
2576#[derive(Args, Debug)]
2577pub struct RunnersLogsCmd {
2578    #[arg(long, help = "Optional organization ID")]
2579    pub organization_id: Option<String>,
2580    #[arg(long, help = "Optional runner host ID")]
2581    pub runner_host_id: Option<String>,
2582    #[arg(long, help = "Optional runner ID")]
2583    pub runner_id: Option<String>,
2584    #[arg(long, help = "Optional daemon ID")]
2585    pub daemon_id: Option<String>,
2586    #[arg(long, help = "Optional status filter")]
2587    pub status: Option<String>,
2588    #[arg(long, help = "Optional phase filter")]
2589    pub phase: Option<String>,
2590    #[arg(long, default_value_t = 10, help = "Maximum jobs to show")]
2591    pub limit: usize,
2592    #[command(flatten)]
2593    pub target: ApiTargetOptions,
2594}
2595
2596#[derive(Args, Debug)]
2597pub struct RunnersSyncCmd {
2598    #[arg(long, help = "Organization ID")]
2599    pub organization_id: String,
2600    #[arg(long, help = "Optional runner host ID")]
2601    pub runner_host_id: Option<String>,
2602    #[arg(long, help = "Optional daemon ID")]
2603    pub daemon_id: Option<String>,
2604    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
2605    pub run_after: Option<String>,
2606    #[arg(long, help = "Payload JSON object")]
2607    pub payload_json: Option<String>,
2608    #[command(flatten)]
2609    pub target: ApiTargetOptions,
2610}
2611
2612#[derive(Args, Debug)]
2613pub struct RunnersDeployCmd {
2614    #[arg(long, help = "Organization ID")]
2615    pub organization_id: String,
2616    #[arg(long, help = "Optional runner host ID")]
2617    pub runner_host_id: Option<String>,
2618    #[arg(long, help = "Optional daemon ID")]
2619    pub daemon_id: Option<String>,
2620    #[arg(long, help = "Optional existing runner ID")]
2621    pub runner_id: Option<String>,
2622    #[arg(long, help = "Optional priority")]
2623    pub priority: Option<i32>,
2624    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
2625    pub run_after: Option<String>,
2626    #[arg(long, help = "Payload JSON object")]
2627    pub payload_json: Option<String>,
2628    #[command(flatten)]
2629    pub target: ApiTargetOptions,
2630}
2631
2632#[derive(Args, Debug)]
2633pub struct RunnersUpdateCmd {
2634    #[arg(long, help = "Organization ID")]
2635    pub organization_id: String,
2636    #[arg(long, help = "Optional runner host ID")]
2637    pub runner_host_id: Option<String>,
2638    #[arg(long, help = "Optional daemon ID")]
2639    pub daemon_id: Option<String>,
2640    #[arg(long, help = "Optional existing runner ID")]
2641    pub runner_id: Option<String>,
2642    #[arg(long, help = "Optional priority")]
2643    pub priority: Option<i32>,
2644    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
2645    pub run_after: Option<String>,
2646    #[arg(long, help = "Payload JSON object")]
2647    pub payload_json: Option<String>,
2648    #[command(flatten)]
2649    pub target: ApiTargetOptions,
2650}
2651
2652#[derive(Args, Debug)]
2653pub struct RunnersRemoveCmd {
2654    #[arg(long, help = "Organization ID")]
2655    pub organization_id: String,
2656    #[arg(long, help = "Existing runner ID")]
2657    pub runner_id: String,
2658    #[arg(long, help = "Optional runner host ID")]
2659    pub runner_host_id: Option<String>,
2660    #[arg(long, help = "Optional daemon ID")]
2661    pub daemon_id: Option<String>,
2662    #[arg(long, help = "Optional priority")]
2663    pub priority: Option<i32>,
2664    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
2665    pub run_after: Option<String>,
2666    #[arg(long, help = "Payload JSON object")]
2667    pub payload_json: Option<String>,
2668    #[command(flatten)]
2669    pub target: ApiTargetOptions,
2670}
2671
2672#[derive(Args, Debug)]
2673pub struct RunnersAgentCmd {
2674    #[command(subcommand)]
2675    pub command: RunnersAgentSubCommand,
2676}
2677
2678#[derive(Subcommand, Debug)]
2679pub enum RunnersAgentSubCommand {
2680    #[command(about = "Serve as a local runner host agent")]
2681    Serve(RunnersAgentServeCmd),
2682}
2683
2684#[derive(Args, Debug)]
2685pub struct RunnersAgentServeCmd {
2686    #[arg(long, help = "Runner host ID")]
2687    pub runner_host_id: String,
2688    #[arg(long, default_value_t = 15, help = "Polling interval in seconds")]
2689    pub interval_seconds: u64,
2690    #[arg(long, help = "Process at most one job and exit")]
2691    pub once: bool,
2692    #[command(flatten)]
2693    pub target: ApiTargetOptions,
2694}
2695
2696#[derive(Args, Debug)]
2697#[command(
2698    arg_required_else_help = true,
2699    disable_help_subcommand = true,
2700    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2701    after_help = "Examples:\n  xbp mcp serve\n  xbp mcp serve --tray\n  xbp mcp serve --detach\n  xbp mcp config\n  xbp mcp inspector --json\n  xbp mcp status\n  xbp mcp install --port 1113\n  xbp mcp export-tools -o crates/mcp/generated/catalog.json\n\nCursor MCP url: http://127.0.0.1:1113/sse"
2702)]
2703pub struct McpCmd {
2704    #[command(subcommand)]
2705    pub command: McpSubCommand,
2706}
2707
2708#[derive(Subcommand, Debug)]
2709pub enum McpSubCommand {
2710    #[command(about = "Start the MCP HTTP/SSE server (foreground or detached)")]
2711    Serve(McpServeCmd),
2712    #[command(about = "Install and enable xbp-mcp.service via systemd")]
2713    Install(McpInstallCmd),
2714    #[command(about = "Check whether the MCP server is reachable")]
2715    Status(McpStatusCmd),
2716    #[command(about = "Print Cursor MCP configuration JSON")]
2717    Config(McpConfigCmd),
2718    #[command(about = "Print prefilled MCP Inspector URLs and tool-call presets")]
2719    Inspector(McpInspectorCmd),
2720    #[command(
2721        about = "Export the full clap-derived MCP tool catalog as JSON (regenerate crates/mcp/generated/catalog.json)"
2722    )]
2723    ExportTools(McpExportToolsCmd),
2724}
2725
2726#[derive(Args, Debug)]
2727pub struct McpExportToolsCmd {
2728    #[arg(
2729        long,
2730        short = 'o',
2731        help = "Write catalog JSON to this path (default: stdout)"
2732    )]
2733    pub output: Option<PathBuf>,
2734}
2735
2736#[derive(Args, Debug)]
2737pub struct McpServeCmd {
2738    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
2739    pub bind: String,
2740    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
2741    pub port: u16,
2742    #[arg(
2743        long,
2744        help = "Spawn a background MCP server process and print Cursor config"
2745    )]
2746    pub detach: bool,
2747    #[arg(long, help = "Use stdio MCP transport instead of HTTP/SSE")]
2748    pub stdio: bool,
2749    #[arg(
2750        long,
2751        help = "Show a system tray icon (xbp.app icon) with a Quit action"
2752    )]
2753    pub tray: bool,
2754}
2755
2756#[derive(Args, Debug)]
2757pub struct McpInstallCmd {
2758    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
2759    pub bind: String,
2760    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
2761    pub port: u16,
2762}
2763
2764#[derive(Args, Debug)]
2765pub struct McpStatusCmd {
2766    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
2767    pub bind: String,
2768    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
2769    pub port: u16,
2770}
2771
2772#[derive(Args, Debug)]
2773pub struct McpConfigCmd {
2774    #[arg(long, default_value = "127.0.0.1", help = "Bind address")]
2775    pub bind: String,
2776    #[arg(long, default_value_t = 1113, help = "HTTP port for MCP SSE transport")]
2777    pub port: u16,
2778}
2779
2780#[derive(Args, Debug)]
2781pub struct McpInspectorCmd {
2782    #[arg(
2783        long,
2784        default_value = "127.0.0.1",
2785        help = "Bind address for the xbp MCP server"
2786    )]
2787    pub bind: String,
2788    #[arg(
2789        long,
2790        default_value_t = 1113,
2791        help = "HTTP port for the xbp MCP SSE transport"
2792    )]
2793    pub port: u16,
2794    #[arg(long, default_value_t = 6274, help = "MCP Inspector UI port")]
2795    pub inspector_port: u16,
2796    #[arg(
2797        long,
2798        help = "Write .xbp/mcp-inspector.json for `npx @modelcontextprotocol/inspector --config`"
2799    )]
2800    pub write_config: bool,
2801    #[arg(
2802        long,
2803        help = "Launch MCP Inspector via npx (writes config first; requires Node.js)"
2804    )]
2805    pub launch: bool,
2806    #[arg(long, help = "Emit machine-readable JSON playbook")]
2807    pub json: bool,
2808}
2809
2810#[derive(Args, Debug)]
2811#[command(
2812    arg_required_else_help = true,
2813    disable_help_subcommand = true,
2814    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
2815    after_help = crate::cli::help_render::WORKTREE_WATCH_AFTER_HELP
2816)]
2817pub struct WorktreeWatchCmd {
2818    #[command(subcommand)]
2819    pub command: WorktreeWatchSubCommand,
2820}
2821
2822#[derive(Subcommand, Debug)]
2823pub enum WorktreeWatchSubCommand {
2824    #[command(about = "Watch the current git worktree and append mutation events locally")]
2825    Start(WorktreeWatchStartCmd),
2826    #[command(about = "Stop a detached background worktree watcher")]
2827    Stop(WorktreeWatchStopCmd),
2828    #[command(about = "Upload unsynced local mutation spool files to xbp.app")]
2829    Sync(WorktreeWatchSyncCmd),
2830    #[command(
2831        about = "Show spool path, sync backlog, and local activity stats",
2832        after_help = crate::cli::help_render::WORKTREE_WATCH_STATUS_AFTER_HELP
2833    )]
2834    Status(WorktreeWatchStatusCmd),
2835    #[command(
2836        about = "Open a native system tray UI to start/stop watchers and view stats (Windows + Linux/GNOME)",
2837        after_help = crate::cli::help_render::WORKTREE_WATCH_TRAY_AFTER_HELP
2838    )]
2839    Tray(WorktreeWatchTrayCmd),
2840}
2841
2842#[derive(Args, Debug, Clone)]
2843pub struct WorktreeWatchTarget {
2844    #[arg(
2845        long,
2846        conflicts_with_all = ["parent", "repos"],
2847        help = "Git repository root, short name, or owner/repo from system inventory; defaults to the current repo"
2848    )]
2849    pub repo: Option<PathBuf>,
2850    #[arg(
2851        long,
2852        conflicts_with_all = ["repo", "repos"],
2853        help = "Folder containing git repositories to target recursively"
2854    )]
2855    pub parent: Option<PathBuf>,
2856    #[arg(
2857        long = "repos",
2858        value_name = "PATH_OR_NAME",
2859        num_args = 1..,
2860        conflicts_with_all = ["repo", "parent"],
2861        help = "One or more git roots, short names, or owner/repo values from system inventory (space-separated)"
2862    )]
2863    pub repos: Vec<PathBuf>,
2864}
2865
2866#[derive(Args, Debug)]
2867pub struct WorktreeWatchTrayCmd {
2868    #[command(flatten)]
2869    pub target: WorktreeWatchTarget,
2870    #[arg(
2871        long,
2872        help = "Keep the tray attached to the terminal instead of backgrounding"
2873    )]
2874    pub foreground: bool,
2875    #[arg(
2876        long,
2877        default_value_t = 60,
2878        help = "Upload interval for watchers started from the tray (seconds; 0 disables)"
2879    )]
2880    pub sync_interval_seconds: u64,
2881}
2882
2883#[derive(Args, Debug)]
2884pub struct WorktreeWatchStartCmd {
2885    #[command(flatten)]
2886    pub target: WorktreeWatchTarget,
2887    #[arg(long, help = "Spawn the watcher as a detached background process")]
2888    pub detach: bool,
2889    #[arg(
2890        long,
2891        default_value_t = 60,
2892        help = "Upload queued events every N seconds while watching; set 0 to disable periodic sync"
2893    )]
2894    pub sync_interval_seconds: u64,
2895    #[arg(long, help = "Record current git commit state once and exit")]
2896    pub once: bool,
2897}
2898
2899#[derive(Args, Debug)]
2900pub struct WorktreeWatchStopCmd {
2901    #[command(flatten)]
2902    pub target: WorktreeWatchTarget,
2903    #[arg(
2904        long,
2905        help = "Stop the PID from watcher state even when the command line cannot be verified"
2906    )]
2907    pub force: bool,
2908}
2909
2910#[derive(Args, Debug)]
2911pub struct WorktreeWatchSyncCmd {
2912    #[command(flatten)]
2913    pub target: WorktreeWatchTarget,
2914    #[arg(long, help = "Print the upload plan without sending it to xbp.app")]
2915    pub dry_run: bool,
2916    #[arg(
2917        long,
2918        help = "Upload all local JSONL spool files, including files already marked synced"
2919    )]
2920    pub resync: bool,
2921}
2922
2923#[derive(Args, Debug)]
2924pub struct WorktreeWatchStatusCmd {
2925    #[command(flatten)]
2926    pub target: WorktreeWatchTarget,
2927    #[arg(long, help = "Emit status as JSON")]
2928    pub json: bool,
2929    #[arg(
2930        long,
2931        help = "Print decoded mutation and commit records from local JSONL spool files"
2932    )]
2933    pub records: bool,
2934    #[arg(
2935        long,
2936        default_value_t = 50,
2937        requires = "records",
2938        help = "Maximum decoded JSONL records to print per repository"
2939    )]
2940    pub record_limit: usize,
2941    #[arg(
2942        long,
2943        help = "Generate and print activity statistics from local JSONL spool files (writes stats.json)"
2944    )]
2945    pub stats: bool,
2946    #[arg(
2947        long,
2948        help = "Generate and print repository activity across all locally spooled branches (writes repo-activity.json)"
2949    )]
2950    pub repo_activity: bool,
2951    #[arg(
2952        long,
2953        default_value_t = 15,
2954        help = "Maximum minutes between mutation events counted as one coding session (used by --stats / --repo-activity)"
2955    )]
2956    pub stats_gap_minutes: u64,
2957}
2958
2959#[derive(Args, Debug, Clone, Default)]
2960pub struct ApiTargetOptions {
2961    #[arg(long, help = "Override the request base URL for this command")]
2962    pub base_url: Option<String>,
2963    #[arg(
2964        long,
2965        help = "Target the hosted web origin (xbp.app) instead of the configured API_XBP_URL base"
2966    )]
2967    pub web: bool,
2968    #[arg(
2969        long,
2970        help = "Skip bearer token auth even when XBP_API_TOKEN is configured"
2971    )]
2972    pub no_auth: bool,
2973    #[arg(
2974        long,
2975        help = "Extra header in 'Name: Value' format",
2976        value_name = "HEADER"
2977    )]
2978    pub header: Vec<String>,
2979    #[arg(long, help = "Print response headers")]
2980    pub include_headers: bool,
2981    #[arg(
2982        long,
2983        help = "Print the response body as-is without JSON pretty formatting"
2984    )]
2985    pub raw: bool,
2986}
2987
2988#[cfg(feature = "docker")]
2989#[derive(Args, Debug)]
2990pub struct DockerCmd {
2991    #[arg(
2992        trailing_var_arg = true,
2993        allow_hyphen_values = true,
2994        help = "Arguments to pass directly to the Docker CLI (default: --help)"
2995    )]
2996    pub args: Vec<String>,
2997}
2998
2999#[derive(Subcommand, Debug)]
3000pub enum ApiSubCommand {
3001    #[command(about = "Install and enable the local xbp-api.service on Linux/systemd")]
3002    Install {
3003        #[arg(long, default_value_t = 8080, help = "Port to expose the API on")]
3004        port: u16,
3005    },
3006    #[command(about = "Call the XBP API health endpoint")]
3007    Health(ApiHealthCmd),
3008    #[command(about = "Manage XBP control-plane projects")]
3009    Projects(ApiProjectsCmd),
3010    #[command(about = "Manage XBP daemon registrations and heartbeats")]
3011    Daemons(ApiDaemonsCmd),
3012    #[command(about = "Manage XBP deployment jobs")]
3013    Jobs(ApiJobsCmd),
3014    #[command(about = "Manage XBP runner hosts, groups, inventory, and runner jobs")]
3015    Runners(ApiRunnersCmd),
3016    #[command(about = "Manage XBP proxy routes on the local API server")]
3017    Routes(ApiRoutesCmd),
3018    #[command(about = "Send an authenticated HTTP request to the configured XBP API surface")]
3019    Request(ApiRequestCmd),
3020}
3021
3022#[derive(Args, Debug)]
3023pub struct ApiHealthCmd {
3024    #[command(flatten)]
3025    pub target: ApiTargetOptions,
3026}
3027
3028#[derive(Args, Debug)]
3029pub struct ApiProjectsCmd {
3030    #[command(subcommand)]
3031    pub command: ApiProjectsSubCommand,
3032}
3033
3034#[derive(Subcommand, Debug)]
3035pub enum ApiProjectsSubCommand {
3036    #[command(about = "List projects from the XBP control-plane API")]
3037    List(ApiProjectsListCmd),
3038    #[command(about = "Create or upsert a control-plane project")]
3039    Create(Box<ApiProjectsCreateCmd>),
3040}
3041
3042#[derive(Args, Debug)]
3043pub struct ApiProjectsListCmd {
3044    #[arg(long, help = "Optional organization ID filter")]
3045    pub organization_id: Option<String>,
3046    #[command(flatten)]
3047    pub target: ApiTargetOptions,
3048}
3049
3050#[derive(Args, Debug)]
3051pub struct ApiProjectsCreateCmd {
3052    #[arg(long, help = "Project name")]
3053    pub name: String,
3054    #[arg(long, help = "Project path or repo path key")]
3055    pub path: String,
3056    #[arg(long, help = "Optional organization ID")]
3057    pub organization_id: Option<String>,
3058    #[arg(long, help = "Optional project slug")]
3059    pub slug: Option<String>,
3060    #[arg(long, help = "Optional project version")]
3061    pub version: Option<String>,
3062    #[arg(long, help = "Optional build directory")]
3063    pub build_dir: Option<String>,
3064    #[arg(long, help = "Optional runtime enum value")]
3065    pub runtime: Option<String>,
3066    #[arg(long, help = "Optional default branch")]
3067    pub default_branch: Option<String>,
3068    #[arg(long, help = "Optional repository root directory")]
3069    pub root_directory: Option<String>,
3070    #[arg(long, help = "Optional build command")]
3071    pub build_command: Option<String>,
3072    #[arg(long, help = "Optional install command")]
3073    pub install_command: Option<String>,
3074    #[arg(long, help = "Optional start command")]
3075    pub start_command: Option<String>,
3076    #[arg(long, help = "Optional output directory")]
3077    pub output_directory: Option<String>,
3078    #[arg(long, help = "Repository JSON payload matching GitRepositoryRef")]
3079    pub repository_json: Option<String>,
3080    #[arg(long, help = "Runtime policy JSON payload")]
3081    pub runtime_policy_json: Option<String>,
3082    #[arg(long, help = "Metadata JSON object")]
3083    pub metadata_json: Option<String>,
3084    #[command(flatten)]
3085    pub target: ApiTargetOptions,
3086}
3087
3088#[derive(Args, Debug)]
3089pub struct ApiDaemonsCmd {
3090    #[command(subcommand)]
3091    pub command: ApiDaemonsSubCommand,
3092}
3093
3094#[derive(Subcommand, Debug)]
3095pub enum ApiDaemonsSubCommand {
3096    #[command(about = "List registered daemons")]
3097    List(ApiDaemonsListCmd),
3098    #[command(about = "Register or upsert a daemon record")]
3099    Register(ApiDaemonsRegisterCmd),
3100    #[command(about = "Post a heartbeat update for a daemon")]
3101    Heartbeat(ApiDaemonsHeartbeatCmd),
3102    #[command(about = "Update daemon status only")]
3103    UpdateStatus(ApiDaemonsUpdateStatusCmd),
3104}
3105
3106#[derive(Args, Debug)]
3107pub struct ApiDaemonsListCmd {
3108    #[command(flatten)]
3109    pub target: ApiTargetOptions,
3110}
3111
3112#[derive(Args, Debug)]
3113pub struct ApiDaemonsRegisterCmd {
3114    #[arg(long, help = "Daemon node name")]
3115    pub node_name: String,
3116    #[arg(long, help = "Daemon hostname")]
3117    pub hostname: String,
3118    #[arg(long, help = "Daemon binary version")]
3119    pub version: String,
3120    #[arg(long, help = "Optional region")]
3121    pub region: Option<String>,
3122    #[arg(long, help = "Optional public IP")]
3123    pub public_ip: Option<String>,
3124    #[arg(long, help = "Optional internal IP")]
3125    pub internal_ip: Option<String>,
3126    #[arg(long, help = "Optional status enum value")]
3127    pub status: Option<String>,
3128    #[arg(long, help = "Optional CPU core count")]
3129    pub cpu_cores: Option<i32>,
3130    #[arg(long, help = "Optional total memory in MB")]
3131    pub memory_total_mb: Option<i32>,
3132    #[arg(long, help = "Optional total disk in GB")]
3133    pub disk_total_gb: Option<i32>,
3134    #[arg(long, help = "Labels JSON object")]
3135    pub labels_json: Option<String>,
3136    #[arg(long, help = "Metadata JSON object")]
3137    pub metadata_json: Option<String>,
3138    #[command(flatten)]
3139    pub target: ApiTargetOptions,
3140}
3141
3142#[derive(Args, Debug)]
3143pub struct ApiDaemonsHeartbeatCmd {
3144    #[arg(help = "Daemon ID")]
3145    pub daemon_id: String,
3146    #[arg(long, help = "Optional status enum value")]
3147    pub status: Option<String>,
3148    #[arg(long, help = "Optional daemon version")]
3149    pub version: Option<String>,
3150    #[arg(long, help = "Optional public IP")]
3151    pub public_ip: Option<String>,
3152    #[arg(long, help = "Optional internal IP")]
3153    pub internal_ip: Option<String>,
3154    #[arg(long, help = "Optional CPU core count")]
3155    pub cpu_cores: Option<i32>,
3156    #[arg(long, help = "Optional total memory in MB")]
3157    pub memory_total_mb: Option<i32>,
3158    #[arg(long, help = "Optional total disk in GB")]
3159    pub disk_total_gb: Option<i32>,
3160    #[arg(long, help = "Labels JSON object")]
3161    pub labels_json: Option<String>,
3162    #[command(flatten)]
3163    pub target: ApiTargetOptions,
3164}
3165
3166#[derive(Args, Debug)]
3167pub struct ApiDaemonsUpdateStatusCmd {
3168    #[arg(help = "Daemon ID")]
3169    pub daemon_id: String,
3170    #[arg(long, help = "Daemon status enum value")]
3171    pub status: String,
3172    #[command(flatten)]
3173    pub target: ApiTargetOptions,
3174}
3175
3176#[derive(Args, Debug)]
3177pub struct ApiJobsCmd {
3178    #[command(subcommand)]
3179    pub command: ApiJobsSubCommand,
3180}
3181
3182#[derive(Args, Debug)]
3183pub struct ApiRunnersCmd {
3184    #[command(subcommand)]
3185    pub command: ApiRunnersSubCommand,
3186}
3187
3188#[derive(Subcommand, Debug)]
3189pub enum ApiRunnersSubCommand {
3190    #[command(about = "List runner hosts")]
3191    HostsList(ApiRunnersHostsListCmd),
3192    #[command(about = "Register or upsert a runner host")]
3193    HostsEnroll(ApiRunnersHostsEnrollCmd),
3194    #[command(about = "Post a heartbeat update for a runner host")]
3195    HostsHeartbeat(ApiRunnersHostsHeartbeatCmd),
3196    #[command(about = "List persisted preflight records for a runner host")]
3197    HostsPreflightsList(ApiRunnersHostsPreflightsListCmd),
3198    #[command(about = "Create or replace a persisted preflight record for a runner host")]
3199    HostsPreflightsUpsert(ApiRunnersHostsPreflightsUpsertCmd),
3200    #[command(about = "List runner groups for an organization")]
3201    GroupsList(ApiRunnersGroupsListCmd),
3202    #[command(about = "Create or upsert a runner group")]
3203    GroupsCreate(ApiRunnersGroupsCreateCmd),
3204    #[command(about = "Update an existing runner group")]
3205    GroupsUpdate(ApiRunnersGroupsCreateCmd),
3206    #[command(about = "Delete a runner group")]
3207    GroupsDelete(ApiRunnersGroupsDeleteCmd),
3208    #[command(about = "List repository access for a runner group")]
3209    GroupRepositoriesList(ApiRunnersGroupRepositoriesListCmd),
3210    #[command(about = "Grant or upsert repository access for a runner group")]
3211    GroupRepositoriesCreate(ApiRunnersGroupRepositoriesCreateCmd),
3212    #[command(about = "List runner inventory for an organization")]
3213    InventoryList(ApiRunnersInventoryListCmd),
3214    #[command(about = "Upsert a runner inventory record")]
3215    InventoryUpsert(ApiRunnersInventoryUpsertCmd),
3216    #[command(about = "List runner jobs")]
3217    JobsList(ApiRunnersJobsListCmd),
3218    #[command(about = "Create a runner job")]
3219    JobsCreate(ApiRunnersJobsCreateCmd),
3220    #[command(about = "Claim the next runner job")]
3221    JobsClaim(ApiRunnersJobsClaimCmd),
3222    #[command(about = "Update a runner job")]
3223    JobsUpdate(ApiRunnersJobsUpdateCmd),
3224}
3225
3226#[derive(Args, Debug)]
3227pub struct ApiRunnersHostsListCmd {
3228    #[arg(long)]
3229    pub organization_id: Option<String>,
3230    #[arg(long)]
3231    pub daemon_id: Option<String>,
3232    #[arg(long)]
3233    pub status: Option<String>,
3234    #[command(flatten)]
3235    pub target: ApiTargetOptions,
3236}
3237
3238#[derive(Args, Debug)]
3239pub struct ApiRunnersHostsEnrollCmd {
3240    #[arg(long)]
3241    pub organization_id: String,
3242    #[arg(long)]
3243    pub daemon_id: Option<String>,
3244    #[arg(long)]
3245    pub host_kind: String,
3246    #[arg(long)]
3247    pub platform: String,
3248    #[arg(long)]
3249    pub hostname: String,
3250    #[arg(long)]
3251    pub runner_name_prefix: String,
3252    #[arg(long)]
3253    pub display_name: Option<String>,
3254    #[arg(long)]
3255    pub arch: Option<String>,
3256    #[arg(long)]
3257    pub status: Option<String>,
3258    #[arg(long)]
3259    pub labels_json: Option<String>,
3260    #[arg(long)]
3261    pub capabilities_json: Option<String>,
3262    #[arg(long)]
3263    pub metadata_json: Option<String>,
3264    #[command(flatten)]
3265    pub target: ApiTargetOptions,
3266}
3267
3268#[derive(Args, Debug)]
3269pub struct ApiRunnersHostsHeartbeatCmd {
3270    #[arg(help = "Runner host ID")]
3271    pub runner_host_id: String,
3272    #[arg(long)]
3273    pub status: Option<String>,
3274    #[arg(long)]
3275    pub labels_json: Option<String>,
3276    #[arg(long)]
3277    pub capabilities_json: Option<String>,
3278    #[command(flatten)]
3279    pub target: ApiTargetOptions,
3280}
3281
3282#[derive(Args, Debug)]
3283pub struct ApiRunnersHostsPreflightsListCmd {
3284    #[arg(long)]
3285    pub runner_host_id: String,
3286    #[command(flatten)]
3287    pub target: ApiTargetOptions,
3288}
3289
3290#[derive(Args, Debug)]
3291pub struct ApiRunnersHostsPreflightsUpsertCmd {
3292    #[arg(long)]
3293    pub runner_host_id: String,
3294    #[arg(long)]
3295    pub platform: String,
3296    #[arg(long)]
3297    pub status: String,
3298    #[arg(long)]
3299    pub docker_available: Option<bool>,
3300    #[arg(long)]
3301    pub service_manager: Option<String>,
3302    #[arg(long)]
3303    pub checks_json: Option<String>,
3304    #[arg(long)]
3305    pub checked_at: Option<String>,
3306    #[command(flatten)]
3307    pub target: ApiTargetOptions,
3308}
3309
3310#[derive(Args, Debug, Clone)]
3311pub struct ApiRunnersGroupsListCmd {
3312    #[arg(long)]
3313    pub organization_id: String,
3314    #[command(flatten)]
3315    pub target: ApiTargetOptions,
3316}
3317
3318#[derive(Args, Debug, Clone)]
3319pub struct ApiRunnersGroupsCreateCmd {
3320    #[arg(long)]
3321    pub organization_id: String,
3322    #[arg(long)]
3323    pub name: String,
3324    #[arg(long)]
3325    pub visibility: Option<String>,
3326    #[arg(long)]
3327    pub github_installation_id: Option<String>,
3328    #[arg(long)]
3329    pub github_runner_group_id: Option<i64>,
3330    #[arg(long)]
3331    pub sync_state: Option<String>,
3332    #[arg(long)]
3333    pub sync_error: Option<String>,
3334    #[arg(long)]
3335    pub inherited: bool,
3336    #[arg(long)]
3337    pub allows_public_repositories: bool,
3338    #[arg(long)]
3339    pub interactive: bool,
3340    #[arg(long)]
3341    pub restricted_to_workflows: bool,
3342    #[arg(long)]
3343    pub selected_workflows_json: Option<String>,
3344    #[arg(long)]
3345    pub metadata_json: Option<String>,
3346    #[command(flatten)]
3347    pub target: ApiTargetOptions,
3348}
3349
3350#[derive(Args, Debug)]
3351pub struct ApiRunnersGroupsDeleteCmd {
3352    #[arg(help = "Runner group ID")]
3353    pub runner_group_id: String,
3354    #[command(flatten)]
3355    pub target: ApiTargetOptions,
3356}
3357
3358#[derive(Args, Debug)]
3359pub struct ApiRunnersGroupRepositoriesListCmd {
3360    #[arg(help = "Runner group ID")]
3361    pub runner_group_id: String,
3362    #[command(flatten)]
3363    pub target: ApiTargetOptions,
3364}
3365
3366#[derive(Args, Debug)]
3367pub struct ApiRunnersGroupRepositoriesCreateCmd {
3368    #[arg(help = "Runner group ID")]
3369    pub runner_group_id: String,
3370    #[arg(long)]
3371    pub github_repository_id: Option<i64>,
3372    #[arg(long)]
3373    pub repository_owner: String,
3374    #[arg(long)]
3375    pub repository_name: String,
3376    #[arg(long)]
3377    pub repository_full_name: String,
3378    #[arg(long)]
3379    pub is_private: bool,
3380    #[command(flatten)]
3381    pub target: ApiTargetOptions,
3382}
3383
3384#[derive(Args, Debug)]
3385pub struct ApiRunnersInventoryListCmd {
3386    #[arg(long)]
3387    pub organization_id: String,
3388    #[command(flatten)]
3389    pub target: ApiTargetOptions,
3390}
3391
3392#[derive(Args, Debug)]
3393pub struct ApiRunnersInventoryUpsertCmd {
3394    #[arg(long)]
3395    pub organization_id: String,
3396    #[arg(long)]
3397    pub runner_host_id: Option<String>,
3398    #[arg(long)]
3399    pub runner_group_id: Option<String>,
3400    #[arg(long)]
3401    pub github_runner_id: Option<i64>,
3402    #[arg(long)]
3403    pub name: String,
3404    #[arg(long)]
3405    pub platform: String,
3406    #[arg(long)]
3407    pub os: Option<String>,
3408    #[arg(long)]
3409    pub architecture: Option<String>,
3410    #[arg(long)]
3411    pub status: Option<String>,
3412    #[arg(long)]
3413    pub busy: bool,
3414    #[arg(long)]
3415    pub labels_json: Option<String>,
3416    #[arg(long)]
3417    pub metadata_json: Option<String>,
3418    #[command(flatten)]
3419    pub target: ApiTargetOptions,
3420}
3421
3422#[derive(Args, Debug, Clone)]
3423pub struct ApiRunnersJobsListCmd {
3424    #[arg(long)]
3425    pub organization_id: Option<String>,
3426    #[arg(long)]
3427    pub runner_host_id: Option<String>,
3428    #[arg(long)]
3429    pub runner_id: Option<String>,
3430    #[arg(long)]
3431    pub daemon_id: Option<String>,
3432    #[arg(long)]
3433    pub status: Option<String>,
3434    #[arg(long)]
3435    pub phase: Option<String>,
3436    #[arg(long)]
3437    pub limit: Option<usize>,
3438    #[command(flatten)]
3439    pub target: ApiTargetOptions,
3440}
3441
3442#[derive(Args, Debug)]
3443pub struct ApiRunnersJobsCreateCmd {
3444    #[arg(long)]
3445    pub organization_id: String,
3446    #[arg(long)]
3447    pub runner_host_id: Option<String>,
3448    #[arg(long)]
3449    pub daemon_id: Option<String>,
3450    #[arg(long)]
3451    pub runner_id: Option<String>,
3452    #[arg(long)]
3453    pub job_kind: String,
3454    #[arg(long)]
3455    pub priority: Option<i32>,
3456    #[arg(long)]
3457    pub max_attempts: Option<i32>,
3458    #[arg(long)]
3459    pub run_after: Option<String>,
3460    #[arg(long)]
3461    pub payload_json: Option<String>,
3462    #[command(flatten)]
3463    pub target: ApiTargetOptions,
3464}
3465
3466#[derive(Args, Debug)]
3467pub struct ApiRunnersJobsClaimCmd {
3468    #[arg(long)]
3469    pub runner_host_id: Option<String>,
3470    #[arg(long)]
3471    pub daemon_id: Option<String>,
3472    #[arg(long)]
3473    pub locked_by: Option<String>,
3474    #[command(flatten)]
3475    pub target: ApiTargetOptions,
3476}
3477
3478#[derive(Args, Debug)]
3479pub struct ApiRunnersJobsUpdateCmd {
3480    #[arg(help = "Runner job ID")]
3481    pub runner_job_id: String,
3482    #[arg(long)]
3483    pub status: String,
3484    #[arg(long)]
3485    pub error_text: Option<String>,
3486    #[command(flatten)]
3487    pub target: ApiTargetOptions,
3488}
3489
3490#[derive(Subcommand, Debug)]
3491pub enum ApiJobsSubCommand {
3492    #[command(about = "List deployment jobs")]
3493    List(ApiJobsListCmd),
3494    #[command(about = "Create a deployment job for a project")]
3495    Create(ApiJobsCreateCmd),
3496    #[command(about = "Claim the next deployment job for a daemon")]
3497    Claim(ApiJobsClaimCmd),
3498    #[command(about = "Update deployment job status")]
3499    Update(ApiJobsUpdateCmd),
3500}
3501
3502#[derive(Args, Debug)]
3503pub struct ApiJobsListCmd {
3504    #[arg(long, help = "Optional project ID filter")]
3505    pub project_id: Option<String>,
3506    #[arg(long, help = "Optional deployment ID filter")]
3507    pub deployment_id: Option<String>,
3508    #[arg(long, help = "Optional daemon ID filter")]
3509    pub daemon_id: Option<String>,
3510    #[arg(long, help = "Optional status filter")]
3511    pub status: Option<String>,
3512    #[arg(long, help = "Optional result limit")]
3513    pub limit: Option<usize>,
3514    #[command(flatten)]
3515    pub target: ApiTargetOptions,
3516}
3517
3518#[derive(Args, Debug)]
3519pub struct ApiJobsCreateCmd {
3520    #[arg(long, help = "Project ID")]
3521    pub project_id: String,
3522    #[arg(long, help = "Deployment ID")]
3523    pub deployment_id: String,
3524    #[arg(long, help = "Optional daemon ID assignment")]
3525    pub daemon_id: Option<String>,
3526    #[arg(long, help = "Optional priority")]
3527    pub priority: Option<i32>,
3528    #[arg(long, help = "Optional max attempts")]
3529    pub max_attempts: Option<i32>,
3530    #[arg(long, help = "Optional RFC3339 run-after timestamp")]
3531    pub run_after: Option<String>,
3532    #[arg(long, help = "Optional payload JSON object")]
3533    pub payload_json: Option<String>,
3534    #[command(flatten)]
3535    pub target: ApiTargetOptions,
3536}
3537
3538#[derive(Args, Debug)]
3539pub struct ApiJobsClaimCmd {
3540    #[arg(long, help = "Daemon ID claiming work")]
3541    pub daemon_id: String,
3542    #[arg(long, help = "Optional lock owner")]
3543    pub locked_by: Option<String>,
3544    #[command(flatten)]
3545    pub target: ApiTargetOptions,
3546}
3547
3548#[derive(Args, Debug)]
3549pub struct ApiJobsUpdateCmd {
3550    #[arg(help = "Deployment job ID")]
3551    pub job_id: String,
3552    #[arg(long, help = "Deployment job status enum value")]
3553    pub status: String,
3554    #[arg(long, help = "Optional error text")]
3555    pub error_text: Option<String>,
3556    #[command(flatten)]
3557    pub target: ApiTargetOptions,
3558}
3559
3560#[derive(Args, Debug)]
3561pub struct ApiRoutesCmd {
3562    #[command(subcommand)]
3563    pub command: ApiRoutesSubCommand,
3564}
3565
3566#[derive(Subcommand, Debug)]
3567pub enum ApiRoutesSubCommand {
3568    #[command(about = "List configured proxy routes")]
3569    List(ApiRoutesListCmd),
3570    #[command(about = "Create or replace a proxy route")]
3571    Create(ApiRoutesCreateCmd),
3572    #[command(about = "Delete a proxy route by domain")]
3573    Delete(ApiRoutesDeleteCmd),
3574}
3575
3576#[derive(Args, Debug)]
3577pub struct ApiRoutesListCmd {
3578    #[command(flatten)]
3579    pub target: ApiTargetOptions,
3580}
3581
3582#[derive(Args, Debug)]
3583pub struct ApiRoutesCreateCmd {
3584    #[arg(long, help = "Domain name for the route")]
3585    pub domain: String,
3586    #[arg(long, help = "Upstream target URL", required = true)]
3587    pub target: Vec<String>,
3588    #[arg(
3589        long,
3590        help = "Weighted upstream target in url=weight form",
3591        value_name = "URL=WEIGHT"
3592    )]
3593    pub weighted_target: Vec<String>,
3594    #[arg(long, help = "Optional header condition")]
3595    pub header_condition: Option<String>,
3596    #[arg(long, help = "Optional path prefix condition")]
3597    pub path_prefix: Option<String>,
3598    #[command(flatten)]
3599    pub target_options: ApiTargetOptions,
3600}
3601
3602#[derive(Args, Debug)]
3603pub struct ApiRoutesDeleteCmd {
3604    #[arg(help = "Domain name for the route")]
3605    pub domain: String,
3606    #[command(flatten)]
3607    pub target: ApiTargetOptions,
3608}
3609
3610#[derive(Args, Debug)]
3611pub struct ApiRequestCmd {
3612    #[arg(help = "Request path like /projects or a full https:// URL")]
3613    pub path: String,
3614    #[arg(
3615        short = 'X',
3616        long,
3617        help = "HTTP method to use (default: GET, or POST when a body is provided)"
3618    )]
3619    pub method: Option<String>,
3620    #[arg(short = 'd', long, help = "Inline request body string, typically JSON")]
3621    pub body: Option<String>,
3622    #[arg(long, help = "Read the request body from a file")]
3623    pub body_file: Option<PathBuf>,
3624    #[command(flatten)]
3625    pub target: ApiTargetOptions,
3626}
3627#[derive(Args, Debug)]
3628pub struct TailCmd {
3629    #[arg(long, help = "Tail Kafka topic instead of log files")]
3630    pub kafka: bool,
3631    #[arg(long, help = "Ship logs to Kafka")]
3632    pub ship: bool,
3633}
3634
3635#[derive(Args, Debug)]
3636#[command(
3637    arg_required_else_help = true,
3638    disable_help_subcommand = true,
3639    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
3640    after_help = crate::cli::help_render::GENERATE_AFTER_HELP
3641)]
3642pub struct GenerateCmd {
3643    #[command(subcommand)]
3644    pub command: GenerateSubCommand,
3645}
3646
3647#[derive(Subcommand, Debug)]
3648pub enum GenerateSubCommand {
3649    #[command(about = "Generate or update .xbp/xbp.yaml (and convert legacy JSON)")]
3650    Config(GenerateConfigCmd),
3651    #[cfg(feature = "openapi-gen")]
3652    #[command(about = "Generate static OpenAPI documents for configured services")]
3653    Openapi(GenerateOpenApiCmd),
3654    Systemd(GenerateSystemdCmd),
3655}
3656
3657#[cfg(feature = "openapi-gen")]
3658#[derive(Args, Debug)]
3659pub struct GenerateOpenApiCmd {
3660    #[arg(long, action = clap::ArgAction::Append, help = "Generate only the named service (repeatable)")]
3661    pub service: Vec<String>,
3662    #[arg(
3663        long,
3664        conflicts_with = "service",
3665        help = "Generate every eligible service and the aggregate"
3666    )]
3667    pub all: bool,
3668    #[arg(long, value_parser = ["yaml", "json", "both"], help = "Override configured output formats")]
3669    pub format: Option<String>,
3670    #[arg(
3671        long,
3672        help = "Override the output path (requires one service and one format)"
3673    )]
3674    pub output: Option<PathBuf>,
3675    #[arg(long, help = "Fail if generated output differs without writing files")]
3676    pub check: bool,
3677    #[arg(long, help = "Render and validate without writing files")]
3678    pub dry_run: bool,
3679    #[arg(long, help = "Fail when a source type cannot be resolved")]
3680    pub strict: bool,
3681    #[arg(long, help = "Ignore the parsed-source cache")]
3682    pub no_cache: bool,
3683}
3684
3685#[derive(Args, Debug)]
3686pub struct GenerateConfigCmd {
3687    #[arg(
3688        long,
3689        help = "Overwrite .xbp/xbp.yaml if it already exists (default errors when present)"
3690    )]
3691    pub force: bool,
3692    #[arg(
3693        long,
3694        help = "Refresh .xbp/xbp.yaml by applying project detection defaults for missing fields"
3695    )]
3696    pub update: bool,
3697    #[arg(
3698        long,
3699        help = "Path to a legacy xbp.json file to convert into .xbp/xbp.yaml"
3700    )]
3701    pub from_json: Option<PathBuf>,
3702}
3703
3704#[derive(Args, Debug)]
3705#[command(
3706    arg_required_else_help = true,
3707    disable_help_subcommand = true,
3708    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
3709    after_help = "Examples:\n  xbp workers list\n  xbp workers list --failed -n 10\n  xbp workers ls\n  xbp workers logs -f\n  xbp workers logs --build --failed -n 200 -o build.log\n  xbp workers logs --build --grep error --errors-only\n  xbp workers secrets put --environment production --name GITHUB_APP_PRIVATE_KEY --from-stdin\n  xbp workers secrets list --environment production\n  xbp workers settings get --environment production\n  xbp workers wrangler generate-config --output wrangler.deploy.json\n  xbp workers d1 migrations apply DB --remote\n  xbp workers deploy configure --write-config\n  xbp workers deploy run --app athena-studio\n  xbp workers deploy ci --app athena --all\n  xbp workers deploy sync-env-local\n  xbp workers deploy ci --version-upload\n  xbp workers worktree link-dev-vars"
3710)]
3711pub struct WorkersCmd {
3712    #[arg(
3713        long,
3714        help = "Workers project root (defaults to current dir, or apps/web inside the current XBP repo when present)"
3715    )]
3716    pub root: Option<PathBuf>,
3717    #[arg(
3718        long,
3719        help = "Worker app name from .xbp/xbp.yaml `workers:` (defaults to the app matching the current directory)"
3720    )]
3721    pub app: Option<String>,
3722    #[arg(long, help = "Cloudflare API token override")]
3723    pub token: Option<String>,
3724    #[arg(long, help = "Cloudflare account ID override")]
3725    pub account_id: Option<String>,
3726    #[command(subcommand)]
3727    pub command: WorkersSubCommand,
3728}
3729
3730#[derive(Subcommand, Debug)]
3731pub enum WorkersSubCommand {
3732    #[command(
3733        alias = "ls",
3734        about = "List Cloudflare Worker scripts with build and placement status",
3735        after_help = "Examples:\n  xbp workers list\n  xbp workers list --all\n  xbp workers list --failed -n 10\n  xbp workers list --status running --sort modified\n  xbp workers list --json"
3736    )]
3737    List(WorkersListCmd),
3738    #[command(
3739        about = "Show deployment status, stream runtime logs, or fetch Workers Builds CI logs",
3740        after_help = "Examples:\n  xbp workers logs --worker suits-formations --json\n  xbp workers logs -f\n  xbp workers logs -n 100 -o runtime.log\n  xbp workers logs xbp-production\n  xbp workers logs --build --wait\n  xbp workers logs --build --failed -n 200\n  xbp workers logs --build --grep error --errors-only\n  xbp workers logs --build --list-builds"
3741    )]
3742    Logs(WorkersLogsCmd),
3743    #[command(about = "Manage secret bindings for a Worker script or Wrangler environment")]
3744    Secrets(WorkersSecretsCmd),
3745    #[command(about = "Fetch remote Worker settings via the Cloudflare API")]
3746    Settings(WorkersSettingsCmd),
3747    #[command(about = "Inspect and synchronize Durable Object namespace configuration")]
3748    DurableObjects(WorkersDurableObjectsCmd),
3749    #[command(about = "Inspect or generate Wrangler config helpers")]
3750    Wrangler(WorkersWranglerCmd),
3751    #[command(about = "Run Wrangler D1 migration helpers")]
3752    D1(WorkersD1Cmd),
3753    #[command(about = "Run Worker deploy and predeploy helpers")]
3754    Deploy(WorkersDeployCmd),
3755    #[command(about = "Inspect shared worktree paths or link shared dev files")]
3756    Worktree(WorkersWorktreeCmd),
3757    #[command(about = "Show resolved Worker runtime metadata from local env and config files")]
3758    Env(WorkersEnvCmd),
3759}
3760
3761#[derive(Args, Debug, Default)]
3762pub struct WorkersListCmd {
3763    #[arg(
3764        long,
3765        help = "Show every Worker in the account instead of only Workers for this XBP project"
3766    )]
3767    pub all: bool,
3768    #[arg(long, help = "Output the worker inventory as JSON")]
3769    pub json: bool,
3770    #[arg(short = 'n', long = "limit", help = "Show at most N workers")]
3771    pub limit: Option<usize>,
3772    #[arg(
3773        long = "status",
3774        help = "Filter by build status (failed, success, running, unknown)"
3775    )]
3776    pub status: Option<String>,
3777    #[arg(long, help = "Shortcut for --status failed")]
3778    pub failed: bool,
3779    #[arg(
3780        long = "sort",
3781        default_value = "name",
3782        help = "Sort workers by name, modified, or status"
3783    )]
3784    pub sort: String,
3785    #[arg(long = "no-color", help = "Disable ANSI colors in table output")]
3786    pub no_color: bool,
3787}
3788
3789#[derive(Args, Debug)]
3790pub struct WorkersLogsCmd {
3791    #[command(flatten)]
3792    pub target: WorkersTargetArgs,
3793    #[arg(
3794        short = 'f',
3795        long = "follow",
3796        help = "Stream runtime logs until interrupted (wrangler tail)"
3797    )]
3798    pub follow: bool,
3799    #[arg(
3800        long = "build",
3801        help = "Show Workers Builds CI logs instead of runtime deployment output"
3802    )]
3803    pub build: bool,
3804    #[arg(
3805        long = "failed",
3806        help = "When using --build, prefer the latest failed build"
3807    )]
3808    pub failed: bool,
3809    #[arg(long, help = "Output logs as JSON")]
3810    pub json: bool,
3811    #[arg(
3812        short = 'n',
3813        long = "lines",
3814        help = "Show only the last N log lines (deployments/build output)"
3815    )]
3816    pub lines: Option<usize>,
3817    #[arg(
3818        short = 'o',
3819        long = "output",
3820        help = "Write log output to a file instead of stdout"
3821    )]
3822    pub output: Option<PathBuf>,
3823    #[arg(
3824        short = 'g',
3825        long = "grep",
3826        help = "Filter log lines matching this pattern (case-insensitive substring)"
3827    )]
3828    pub grep: Option<String>,
3829    #[arg(
3830        short = 'e',
3831        long = "errors-only",
3832        help = "Show only lines that look like errors or failures"
3833    )]
3834    pub errors_only: bool,
3835    #[arg(long = "no-color", help = "Disable ANSI colors in log output")]
3836    pub no_color: bool,
3837    #[arg(
3838        long = "list-builds",
3839        help = "List recent Workers Builds before fetching logs (with --build)"
3840    )]
3841    pub list_builds: bool,
3842    #[arg(
3843        long = "build-index",
3844        help = "Select build by index from --list-builds (0 = latest)"
3845    )]
3846    pub build_index: Option<usize>,
3847    #[arg(
3848        long,
3849        help = "Poll until the selected Workers Build finishes before fetching logs"
3850    )]
3851    pub wait: bool,
3852    #[arg(long = "no-wait", help = "Skip polling for in-progress Workers Builds")]
3853    pub no_wait: bool,
3854    #[arg(
3855        long = "wait-seconds",
3856        default_value_t = 120,
3857        help = "Maximum seconds to poll an in-progress Workers Build"
3858    )]
3859    pub wait_seconds: u64,
3860    #[arg(help = "Worker script name. When omitted, xbp prompts interactively.")]
3861    pub script_name: Option<String>,
3862}
3863
3864#[derive(Args, Debug, Clone, Default)]
3865pub struct WorkersTargetArgs {
3866    #[arg(
3867        long,
3868        help = "Worker base name (defaults to wrangler config name or xbp)"
3869    )]
3870    pub worker: Option<String>,
3871    #[arg(
3872        long = "environment",
3873        alias = "env",
3874        help = "Wrangler environment name. The remote script resolves to <worker>-<environment>."
3875    )]
3876    pub environment: Option<String>,
3877    #[arg(
3878        long,
3879        help = "Exact remote script name override. When set, this bypasses <worker>-<environment> resolution."
3880    )]
3881    pub script: Option<String>,
3882}
3883
3884#[derive(Args, Debug)]
3885pub struct WorkersSecretsCmd {
3886    #[command(flatten)]
3887    pub target: WorkersTargetArgs,
3888    #[command(subcommand)]
3889    pub command: WorkersSecretsSubCommand,
3890}
3891
3892#[derive(Subcommand, Debug)]
3893pub enum WorkersSecretsSubCommand {
3894    #[command(about = "List secret bindings on the resolved Worker script")]
3895    List(WorkersSecretsListCmd),
3896    #[command(about = "Fetch one secret binding metadata or value")]
3897    Get(WorkersSecretsGetCmd),
3898    #[command(about = "Create or update a secret binding")]
3899    Put(WorkersSecretsPutCmd),
3900    #[command(about = "Delete a secret binding")]
3901    Delete(WorkersSecretsDeleteCmd),
3902    #[command(about = "Create, update, or delete multiple secret bindings from a file")]
3903    Bulk(WorkersSecretsBulkCmd),
3904}
3905
3906#[derive(Args, Debug, Default)]
3907pub struct WorkersSecretsListCmd {}
3908
3909#[derive(Args, Debug)]
3910pub struct WorkersSecretsGetCmd {
3911    #[arg(long, help = "Secret binding name")]
3912    pub name: String,
3913}
3914
3915#[derive(Args, Debug)]
3916pub struct WorkersSecretsPutCmd {
3917    #[arg(long, help = "Secret binding name")]
3918    pub name: String,
3919    #[arg(long, help = "Secret value")]
3920    pub value: Option<String>,
3921    #[arg(long, help = "Read the secret value from stdin instead of --value")]
3922    pub from_stdin: bool,
3923}
3924
3925#[derive(Args, Debug)]
3926pub struct WorkersSecretsDeleteCmd {
3927    #[arg(long, help = "Secret binding name")]
3928    pub name: String,
3929}
3930
3931#[derive(Args, Debug)]
3932pub struct WorkersSecretsBulkCmd {
3933    #[arg(long, help = "Path to a .env or JSON file containing secret updates")]
3934    pub file: PathBuf,
3935    #[arg(
3936        long,
3937        default_value = "env",
3938        help = "Input format: env or json. For json, pass an object mapping names to string values or null for deletes."
3939    )]
3940    pub format: String,
3941}
3942
3943#[derive(Args, Debug)]
3944pub struct WorkersSettingsCmd {
3945    #[command(flatten)]
3946    pub target: WorkersTargetArgs,
3947}
3948
3949#[derive(Args, Debug)]
3950pub struct WorkersDurableObjectsCmd {
3951    #[command(flatten)]
3952    pub target: WorkersTargetArgs,
3953    #[command(subcommand)]
3954    pub command: WorkersDurableObjectsSubCommand,
3955}
3956
3957#[derive(Subcommand, Debug)]
3958pub enum WorkersDurableObjectsSubCommand {
3959    #[command(about = "List configured and live Durable Object namespaces")]
3960    List(WorkersDurableObjectsListCmd),
3961    #[command(about = "Compare local Durable Object configuration with live namespaces")]
3962    Inspect(WorkersDurableObjectsInspectCmd),
3963    #[command(about = "Import live namespace metadata into XBP configuration")]
3964    Sync(WorkersDurableObjectsSyncCmd),
3965}
3966
3967#[derive(Args, Debug, Default)]
3968pub struct WorkersDurableObjectsListCmd {
3969    #[arg(long, help = "Include every namespace in the account")]
3970    pub all: bool,
3971    #[arg(long, help = "Output JSON")]
3972    pub json: bool,
3973}
3974
3975#[derive(Args, Debug, Default)]
3976pub struct WorkersDurableObjectsInspectCmd {
3977    #[arg(long, help = "Include every namespace in the account")]
3978    pub all: bool,
3979    #[arg(long, help = "Output JSON")]
3980    pub json: bool,
3981}
3982
3983#[derive(Args, Debug, Default)]
3984pub struct WorkersDurableObjectsSyncCmd {
3985    #[arg(
3986        long,
3987        help = "Write imported namespace metadata to the local XBP config"
3988    )]
3989    pub apply: bool,
3990    #[arg(
3991        long,
3992        help = "Confirm generation of deploy-affecting migration changes"
3993    )]
3994    pub confirm_migrations: bool,
3995}
3996
3997#[derive(Args, Debug)]
3998pub struct WorkersWranglerCmd {
3999    #[command(subcommand)]
4000    pub command: WorkersWranglerSubCommand,
4001}
4002
4003#[derive(Subcommand, Debug)]
4004pub enum WorkersWranglerSubCommand {
4005    #[command(about = "Generate a Wrangler deploy config JSON file from env vars")]
4006    GenerateConfig(WorkersWranglerGenerateConfigCmd),
4007    #[command(about = "Resolve which Wrangler config file local dev should use")]
4008    ConfigPath(WorkersWranglerConfigPathCmd),
4009    #[command(
4010        name = "run",
4011        alias = "exec",
4012        about = "Run any official Wrangler command from the selected Worker root",
4013        after_help = "Pass Wrangler arguments after `--`. Examples:\n  xbp workers wrangler run -- d1 list\n  xbp workers wrangler run -- d1 execute DB --remote --file schema.sql\n  xbp workers wrangler run -- r2 bucket create r2-bucket\n  xbp workers wrangler run -- r2 bucket cors set r2-bucket --file cors.json\n  xbp workers wrangler run -- queues create xbp-events\n  xbp workers wrangler run -- secrets-store store list --remote"
4014    )]
4015    Run(WorkersWranglerRunCmd),
4016}
4017
4018#[derive(Args, Debug)]
4019pub struct WorkersWranglerGenerateConfigCmd {
4020    #[arg(
4021        long,
4022        default_value = "wrangler.deploy.json",
4023        help = "Output filename, relative to the worker root unless absolute"
4024    )]
4025    pub output: PathBuf,
4026}
4027
4028#[derive(Args, Debug)]
4029pub struct WorkersWranglerConfigPathCmd {
4030    #[arg(
4031        long,
4032        default_value = "serve",
4033        help = "Calling command name, for example serve"
4034    )]
4035    pub command_name: String,
4036    #[arg(
4037        long,
4038        default_value = "development",
4039        help = "Execution mode, for example development or production"
4040    )]
4041    pub mode: String,
4042}
4043
4044#[derive(Args, Debug)]
4045pub struct WorkersWranglerRunCmd {
4046    #[arg(
4047        required = true,
4048        trailing_var_arg = true,
4049        allow_hyphen_values = true,
4050        help = "Arguments passed verbatim to the official Wrangler CLI"
4051    )]
4052    pub args: Vec<String>,
4053}
4054
4055#[derive(Args, Debug)]
4056pub struct WorkersD1Cmd {
4057    #[command(subcommand)]
4058    pub command: WorkersD1SubCommand,
4059}
4060
4061#[derive(Subcommand, Debug)]
4062pub enum WorkersD1SubCommand {
4063    #[command(about = "Apply pending Wrangler D1 migrations")]
4064    Migrations(WorkersD1MigrationsCmd),
4065}
4066
4067#[derive(Args, Debug)]
4068pub struct WorkersD1MigrationsCmd {
4069    #[command(subcommand)]
4070    pub command: WorkersD1MigrationsSubCommand,
4071}
4072
4073#[derive(Subcommand, Debug)]
4074pub enum WorkersD1MigrationsSubCommand {
4075    #[command(about = "Apply pending migrations to a local or remote D1 database")]
4076    Apply(WorkersD1MigrationsApplyCmd),
4077}
4078
4079#[derive(Args, Debug)]
4080pub struct WorkersD1MigrationsApplyCmd {
4081    #[arg(help = "D1 database binding or name, for example DB")]
4082    pub database: String,
4083    #[arg(
4084        long,
4085        conflicts_with = "remote",
4086        help = "Apply migrations to the local Wrangler D1 database"
4087    )]
4088    pub local: bool,
4089    #[arg(
4090        long,
4091        conflicts_with = "local",
4092        help = "Apply migrations to the remote D1 database"
4093    )]
4094    pub remote: bool,
4095    #[arg(long, help = "Wrangler config path override")]
4096    pub config: Option<PathBuf>,
4097    #[command(flatten)]
4098    pub target: WorkersTargetArgs,
4099    #[arg(
4100        long,
4101        help = "Persist local D1 state to this directory. When omitted in a git worktree, xbp uses the shared .wrangler/state path automatically."
4102    )]
4103    pub persist_to: Option<PathBuf>,
4104    #[arg(
4105        long,
4106        help = "Disable the automatic shared .wrangler/state path when running local migrations from a git worktree"
4107    )]
4108    pub no_shared_worktree_state: bool,
4109}
4110
4111#[derive(Args, Debug)]
4112pub struct WorkersDeployCmd {
4113    #[arg(
4114        long,
4115        help = "Run the deploy action for every worker app configured in .xbp/xbp.yaml"
4116    )]
4117    pub all: bool,
4118    #[command(subcommand)]
4119    pub command: WorkersDeploySubCommand,
4120}
4121
4122#[derive(Subcommand, Debug)]
4123pub enum WorkersDeploySubCommand {
4124    #[command(
4125        about = "Discover worker apps, sync Wrangler config, and optionally write .xbp/xbp.yaml"
4126    )]
4127    Configure(WorkersDeployConfigureCmd),
4128    #[command(about = "Run the configured deploy command for one or more worker apps")]
4129    Run(WorkersDeployRunCmd),
4130    #[command(about = "Run the predeploy sync flow unless Workers CI mode is active")]
4131    Predeploy(WorkersDeployPredeployCmd),
4132    #[command(about = "Read .env.local or process env, then emit .dev.vars and Wrangler configs")]
4133    SyncEnvLocal(WorkersDeploySyncEnvLocalCmd),
4134    #[command(
4135        about = "Run the built-in Cloudflare Worker CI deploy workflow (build + wrangler deploy)"
4136    )]
4137    Ci(WorkersDeployCiCmd),
4138    #[command(
4139        about = "Run the existing deploy-selection flow that chooses CI or local deploy behavior"
4140    )]
4141    Select(WorkersDeploySelectCmd),
4142}
4143
4144#[derive(Args, Debug, Default)]
4145pub struct WorkersDeployConfigureCmd {
4146    #[arg(
4147        long,
4148        help = "Scan the repo for Wrangler projects and merge them into .xbp/xbp.yaml `workers:`"
4149    )]
4150    pub write_config: bool,
4151    #[arg(
4152        long,
4153        help = "Preview worker discovery and config writes without changing files"
4154    )]
4155    pub dry_run: bool,
4156}
4157
4158#[derive(Args, Debug, Default)]
4159pub struct WorkersDeployRunCmd {}
4160
4161#[derive(Args, Debug)]
4162pub struct WorkersDeployPredeployCmd {
4163    #[arg(long, help = "Force Workers CI mode and skip local sync")]
4164    pub ci: bool,
4165}
4166
4167#[derive(Args, Debug)]
4168pub struct WorkersDeploySyncEnvLocalCmd {}
4169
4170#[derive(Args, Debug)]
4171pub struct WorkersDeployCiCmd {
4172    #[arg(long, help = "Upload a new version without immediately deploying it")]
4173    pub version_upload: bool,
4174}
4175
4176#[derive(Args, Debug)]
4177pub struct WorkersDeploySelectCmd {
4178    #[arg(long, help = "Force the WORKERS_CI=1 branch of the selector")]
4179    pub ci: bool,
4180    #[arg(
4181        long,
4182        help = "Branch name to expose as WORKERS_CI_BRANCH when --ci is set"
4183    )]
4184    pub branch: Option<String>,
4185}
4186
4187#[derive(Args, Debug)]
4188pub struct WorkersWorktreeCmd {
4189    #[command(subcommand)]
4190    pub command: WorkersWorktreeSubCommand,
4191}
4192
4193#[derive(Subcommand, Debug)]
4194pub enum WorkersWorktreeSubCommand {
4195    #[command(about = "Print repo-root, primary worktree, and shared Wrangler state paths")]
4196    Paths(WorkersWorktreePathsCmd),
4197    #[command(
4198        about = "Symlink apps/web/.dev.vars and wrangler.dev.jsonc from the primary worktree when in a linked worktree"
4199    )]
4200    LinkDevVars(WorkersWorktreeLinkDevVarsCmd),
4201}
4202
4203#[derive(Args, Debug, Default)]
4204pub struct WorkersWorktreePathsCmd {}
4205
4206#[derive(Args, Debug, Default)]
4207pub struct WorkersWorktreeLinkDevVarsCmd {}
4208
4209#[derive(Args, Debug)]
4210pub struct WorkersEnvCmd {
4211    #[command(flatten)]
4212    pub target: WorkersTargetArgs,
4213    #[arg(
4214        long,
4215        help = "Show resolved plain-text binding values instead of masking them"
4216    )]
4217    pub show_values: bool,
4218}
4219
4220#[derive(Args, Debug)]
4221#[command(
4222    arg_required_else_help = true,
4223    disable_help_subcommand = true,
4224    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
4225    after_help = "Examples:\n  xbp cloudflare doctor --app auth\n  xbp cloudflare init --app auth --worker-root apps/auth-worker --container-port 8787 --health-path /health --write\n  xbp cloudflare deploy --app auth --rollout immediate --dry-run\n  xbp cloudflare release --app auth --version 1.2.3 --rollout gradual\n  xbp cloudflare containers instances --app auth\n  xbp cloudflare jobs enqueue deploy --app auth --rollout immediate"
4226)]
4227pub struct CloudflareCmd {
4228    #[arg(
4229        long,
4230        global = true,
4231        help = "XBP project root (defaults to the nearest directory containing .xbp/xbp.yaml)"
4232    )]
4233    pub root: Option<PathBuf>,
4234    #[arg(
4235        long,
4236        global = true,
4237        help = "Worker app name from .xbp/xbp.yaml `workers:`"
4238    )]
4239    pub app: Option<String>,
4240    #[arg(long, global = true, help = "Cloudflare API token override")]
4241    pub token: Option<String>,
4242    #[arg(long, global = true, help = "Cloudflare account ID override")]
4243    pub account_id: Option<String>,
4244    #[command(subcommand)]
4245    pub command: CloudflareSubCommand,
4246}
4247
4248#[derive(Subcommand, Debug)]
4249pub enum CloudflareSubCommand {
4250    #[command(
4251        about = "Validate Wrangler, container metadata, bindings, secrets, and deploy readiness"
4252    )]
4253    Doctor(CloudflareDoctorCmd),
4254    #[command(about = "Discover or write the project-level Worker container contract")]
4255    Init(CloudflareInitCmd),
4256    #[command(about = "Run the canonical Worker + Container deploy workflow")]
4257    Deploy(CloudflareDeployCmd),
4258    #[command(about = "Sync a version-bearing deploy var, deploy, and verify live health")]
4259    Release(CloudflareReleaseCmd),
4260    #[command(about = "Wrap official Wrangler container commands from the selected app root")]
4261    Containers(CloudflareContainersCmd),
4262    #[command(
4263        about = "Manage Cloudflare Tunnels through official Wrangler commands",
4264        after_help = "Tunnel commands are experimental in Wrangler. Examples:\n  xbp cloudflare tunnel create my-app\n  xbp cloudflare tunnel list\n  xbp cloudflare tunnel info my-app\n  xbp cloudflare tunnel run my-app\n  xbp cloudflare tunnel run --token <TOKEN>\n  xbp cloudflare tunnel quick-start http://localhost:8080\n  xbp cloudflare tunnel delete my-app --force"
4265    )]
4266    Tunnel(CloudflareTunnelCmd),
4267    #[command(
4268        about = "Manage Cloudflare Workflows through official Wrangler commands",
4269        after_help = "Pass the documented Wrangler workflows command after `workflows`. Examples:\n  xbp cloudflare workflows list\n  xbp cloudflare workflows describe my-workflow\n  xbp cloudflare workflows trigger my-workflow '{\"key\":\"value\"}'\n  xbp cloudflare workflows instances list my-workflow --local\n  xbp cloudflare workflows instances describe my-workflow latest\n  xbp cloudflare workflows instances send-event my-workflow latest --type my-event --payload '{\"key\":\"value\"}'\n  xbp cloudflare workflows instances pause my-workflow latest\n  xbp cloudflare workflows instances resume my-workflow latest\n  xbp cloudflare workflows instances restart my-workflow latest\n  xbp cloudflare workflows instances terminate my-workflow latest"
4270    )]
4271    Workflows(CloudflareWorkflowsCmd),
4272    #[command(
4273        about = "Run Wrangler local-development commands with environment-specific vars",
4274        after_help = "Pass the documented Wrangler local-development command after `env`. Examples:\n  xbp cloudflare env dev\n  xbp cloudflare env dev --env staging\n  xbp cloudflare env dev --env staging --port 8787"
4275    )]
4276    Env(CloudflareEnvCmd),
4277    #[command(about = "Manage local file-backed Cloudflare workflow jobs")]
4278    Jobs(CloudflareJobsCmd),
4279}
4280
4281#[derive(Args, Debug, Default)]
4282pub struct CloudflareDoctorCmd {
4283    #[arg(
4284        long,
4285        help = "Skip live Wrangler calls and only validate local files/config"
4286    )]
4287    pub offline: bool,
4288}
4289
4290#[derive(Args, Debug)]
4291pub struct CloudflareInitCmd {
4292    #[arg(long, help = "Worker app root to store in .xbp/xbp.yaml")]
4293    pub worker_root: PathBuf,
4294    #[arg(long, help = "Container port exposed by the runtime")]
4295    pub container_port: u16,
4296    #[arg(
4297        long,
4298        default_value = "/health",
4299        help = "Container health endpoint path"
4300    )]
4301    pub health_path: String,
4302    #[arg(long, help = "Container Durable Object class name")]
4303    pub class_name: Option<String>,
4304    #[arg(long, help = "Worker binding name for the container Durable Object")]
4305    pub binding: Option<String>,
4306    #[arg(
4307        long,
4308        default_value = "Dockerfile",
4309        help = "Dockerfile path relative to the Worker root"
4310    )]
4311    pub dockerfile: PathBuf,
4312    #[arg(long, help = "Cloudflare container application id")]
4313    pub application_id: Option<String>,
4314    #[arg(
4315        long = "required-secret",
4316        help = "Required secret/env binding name",
4317        value_name = "NAME"
4318    )]
4319    pub required_secrets: Vec<String>,
4320    #[arg(long, help = "Wrangler vars key that should carry release versions")]
4321    pub version_var: Option<String>,
4322    #[arg(
4323        long,
4324        help = "Persist the discovered/scaffolded contract to .xbp/xbp.yaml"
4325    )]
4326    pub write: bool,
4327}
4328
4329#[derive(Args, Debug)]
4330pub struct CloudflareDeployCmd {
4331    #[arg(long, value_enum, default_value_t = CloudflareRollout::Immediate)]
4332    pub rollout: CloudflareRollout,
4333    #[arg(long, help = "Run validation and Wrangler deploy --dry-run only")]
4334    pub dry_run: bool,
4335    #[arg(
4336        long,
4337        help = "Run post-deploy verification without invoking Wrangler deploy"
4338    )]
4339    pub skip_deploy: bool,
4340    #[arg(
4341        long,
4342        help = "Allow the container image tag to remain unchanged after deploy verification"
4343    )]
4344    pub allow_unchanged_container_image: bool,
4345    #[arg(
4346        long,
4347        help = "Delete old container image tags after successful verification"
4348    )]
4349    pub prune_old_images: bool,
4350    #[arg(long, help = "Image tag count to retain when pruning old tags")]
4351    pub keep_image_tag_count: Option<usize>,
4352}
4353
4354#[derive(Args, Debug)]
4355pub struct CloudflareReleaseCmd {
4356    #[arg(long, help = "Release version to write to the configured version var")]
4357    pub version: String,
4358    #[arg(
4359        long,
4360        help = "Release domain that must validate before this container-backed Worker release"
4361    )]
4362    pub domain: Option<String>,
4363    #[arg(long, value_enum, default_value_t = CloudflareRollout::Immediate)]
4364    pub rollout: CloudflareRollout,
4365    #[arg(
4366        long,
4367        help = "Run release verification without invoking Wrangler deploy"
4368    )]
4369    pub skip_deploy: bool,
4370    #[arg(
4371        long,
4372        help = "Allow the container image tag to remain unchanged after release verification"
4373    )]
4374    pub allow_unchanged_container_image: bool,
4375    #[arg(
4376        long,
4377        help = "Delete old container image tags after successful verification"
4378    )]
4379    pub prune_old_images: bool,
4380    #[arg(long, help = "Image tag count to retain when pruning old tags")]
4381    pub keep_image_tag_count: Option<usize>,
4382}
4383
4384#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
4385#[serde(rename_all = "kebab-case")]
4386pub enum CloudflareRollout {
4387    Immediate,
4388    Gradual,
4389    None,
4390}
4391
4392impl CloudflareRollout {
4393    pub fn as_str(self) -> &'static str {
4394        match self {
4395            Self::Immediate => "immediate",
4396            Self::Gradual => "gradual",
4397            Self::None => "none",
4398        }
4399    }
4400}
4401
4402#[derive(Args, Debug)]
4403pub struct CloudflareContainersCmd {
4404    #[command(subcommand)]
4405    pub command: CloudflareContainersSubCommand,
4406}
4407
4408#[derive(Args, Debug)]
4409pub struct CloudflareTunnelCmd {
4410    #[command(subcommand)]
4411    pub command: CloudflareTunnelSubCommand,
4412}
4413
4414#[derive(Subcommand, Debug)]
4415pub enum CloudflareTunnelSubCommand {
4416    #[command(about = "Create a remotely managed Cloudflare Tunnel")]
4417    Create(CloudflareTunnelCreateCmd),
4418    #[command(about = "Delete a Cloudflare Tunnel")]
4419    Delete(CloudflareTunnelDeleteCmd),
4420    #[command(about = "Show details for a Cloudflare Tunnel")]
4421    Info(CloudflareTunnelInfoCmd),
4422    #[command(about = "List Cloudflare Tunnels")]
4423    List(CloudflareTunnelListCmd),
4424    #[command(about = "Run a named or token-authenticated Cloudflare Tunnel")]
4425    Run(CloudflareTunnelRunCmd),
4426    #[command(about = "Start a temporary anonymous Quick Tunnel")]
4427    QuickStart(CloudflareTunnelQuickStartCmd),
4428}
4429
4430#[derive(Args, Debug)]
4431pub struct CloudflareTunnelCreateCmd {
4432    #[arg(help = "Unique tunnel name within the Cloudflare account")]
4433    pub name: String,
4434}
4435
4436#[derive(Args, Debug)]
4437pub struct CloudflareTunnelDeleteCmd {
4438    #[arg(help = "Tunnel name or UUID")]
4439    pub tunnel: String,
4440    #[arg(long, help = "Skip the confirmation prompt")]
4441    pub force: bool,
4442}
4443
4444#[derive(Args, Debug)]
4445pub struct CloudflareTunnelInfoCmd {
4446    #[arg(help = "Tunnel name or UUID")]
4447    pub tunnel: String,
4448}
4449
4450#[derive(Args, Debug, Default)]
4451pub struct CloudflareTunnelListCmd {}
4452
4453#[derive(Args, Debug)]
4454pub struct CloudflareTunnelRunCmd {
4455    #[arg(help = "Tunnel name or UUID; omit when using --token")]
4456    pub tunnel: Option<String>,
4457    #[arg(long, help = "Tunnel token; avoids API lookup and authentication")]
4458    pub token: Option<String>,
4459    #[arg(
4460        long,
4461        help = "cloudflared log level: debug, info, warn, error, or fatal"
4462    )]
4463    pub log_level: Option<String>,
4464}
4465
4466#[derive(Args, Debug)]
4467pub struct CloudflareTunnelQuickStartCmd {
4468    #[arg(help = "Local URL to expose, for example http://localhost:8080")]
4469    pub url: String,
4470}
4471
4472#[derive(Args, Debug)]
4473pub struct CloudflareWorkflowsCmd {
4474    #[command(subcommand)]
4475    pub command: CloudflareWorkflowsSubCommand,
4476}
4477
4478#[derive(Subcommand, Debug)]
4479pub enum CloudflareWorkflowsSubCommand {
4480    #[command(external_subcommand)]
4481    External(Vec<String>),
4482}
4483
4484#[derive(Args, Debug)]
4485pub struct CloudflareEnvCmd {
4486    #[command(subcommand)]
4487    pub command: CloudflareEnvSubCommand,
4488}
4489
4490#[derive(Subcommand, Debug)]
4491pub enum CloudflareEnvSubCommand {
4492    #[command(external_subcommand)]
4493    External(Vec<String>),
4494}
4495
4496#[derive(Subcommand, Debug)]
4497pub enum CloudflareContainersSubCommand {
4498    #[command(about = "Run wrangler containers list")]
4499    List(CloudflareContainersListCmd),
4500    #[command(
4501        about = "Run wrangler containers info for the configured or supplied application id"
4502    )]
4503    Info(CloudflareContainersInfoCmd),
4504    #[command(
4505        about = "Run wrangler containers instances for the configured or supplied application id"
4506    )]
4507    Instances(CloudflareContainersInstancesCmd),
4508    #[command(about = "Run wrangler containers push")]
4509    Push(CloudflareContainersPushCmd),
4510    #[command(about = "Open wrangler containers ssh for an instance")]
4511    Ssh(CloudflareContainersSshCmd),
4512}
4513
4514#[derive(Args, Debug, Default)]
4515pub struct CloudflareContainersListCmd {
4516    #[arg(long, help = "Ask Wrangler for JSON output when supported")]
4517    pub json: bool,
4518}
4519
4520#[derive(Args, Debug, Default)]
4521pub struct CloudflareContainersInfoCmd {
4522    #[arg(long, help = "Container application id override")]
4523    pub application_id: Option<String>,
4524    #[arg(long, help = "Ask Wrangler for JSON output when supported")]
4525    pub json: bool,
4526}
4527
4528#[derive(Args, Debug, Default)]
4529pub struct CloudflareContainersInstancesCmd {
4530    #[arg(long, help = "Container application id override")]
4531    pub application_id: Option<String>,
4532    #[arg(long, help = "Ask Wrangler for JSON output when supported")]
4533    pub json: bool,
4534}
4535
4536#[derive(Args, Debug)]
4537pub struct CloudflareContainersPushCmd {
4538    #[arg(help = "Image tag or image name to push through Wrangler")]
4539    pub image: String,
4540}
4541
4542#[derive(Args, Debug)]
4543pub struct CloudflareContainersSshCmd {
4544    #[arg(help = "Container instance id")]
4545    pub instance_id: String,
4546    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
4547    pub args: Vec<String>,
4548}
4549
4550#[derive(Args, Debug)]
4551pub struct CloudflareJobsCmd {
4552    #[command(subcommand)]
4553    pub command: CloudflareJobsSubCommand,
4554}
4555
4556#[derive(Subcommand, Debug)]
4557pub enum CloudflareJobsSubCommand {
4558    #[command(about = "Queue a local Cloudflare workflow job")]
4559    Enqueue(CloudflareJobsEnqueueCmd),
4560    #[command(about = "Run queued local Cloudflare workflow jobs")]
4561    Run(CloudflareJobsRunCmd),
4562    #[command(about = "Show one local Cloudflare workflow job")]
4563    Status(CloudflareJobsStatusCmd),
4564    #[command(about = "List local Cloudflare workflow jobs")]
4565    List(CloudflareJobsListCmd),
4566    #[command(about = "Print stored logs for one local Cloudflare workflow job")]
4567    Logs(CloudflareJobsLogsCmd),
4568}
4569
4570#[derive(Args, Debug)]
4571pub struct CloudflareJobsEnqueueCmd {
4572    #[arg(value_enum)]
4573    pub workflow: CloudflareJobWorkflow,
4574    #[arg(long, value_enum, default_value_t = CloudflareRollout::Immediate)]
4575    pub rollout: CloudflareRollout,
4576    #[arg(long, help = "Required for release jobs")]
4577    pub version: Option<String>,
4578    #[arg(long, help = "Queue deploy jobs in dry-run mode")]
4579    pub dry_run: bool,
4580    #[arg(long, help = "Queue verification without invoking Wrangler deploy")]
4581    pub skip_deploy: bool,
4582    #[arg(long, help = "Allow an unchanged container image during verification")]
4583    pub allow_unchanged_container_image: bool,
4584    #[arg(long, help = "Prune old container image tags after verification")]
4585    pub prune_old_images: bool,
4586    #[arg(long, help = "Image tag count to retain when pruning old tags")]
4587    pub keep_image_tag_count: Option<usize>,
4588}
4589
4590#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
4591#[serde(rename_all = "kebab-case")]
4592pub enum CloudflareJobWorkflow {
4593    Doctor,
4594    Deploy,
4595    Release,
4596}
4597
4598impl CloudflareJobWorkflow {
4599    pub fn as_str(self) -> &'static str {
4600        match self {
4601            Self::Doctor => "doctor",
4602            Self::Deploy => "deploy",
4603            Self::Release => "release",
4604        }
4605    }
4606}
4607
4608#[derive(Args, Debug, Default)]
4609pub struct CloudflareJobsRunCmd {
4610    #[arg(long, help = "Run only the next queued job")]
4611    pub once: bool,
4612}
4613
4614#[derive(Args, Debug)]
4615pub struct CloudflareJobsStatusCmd {
4616    pub job_id: String,
4617}
4618
4619#[derive(Args, Debug, Default)]
4620pub struct CloudflareJobsListCmd {}
4621
4622#[derive(Args, Debug)]
4623pub struct CloudflareJobsLogsCmd {
4624    pub job_id: String,
4625}
4626
4627#[cfg(feature = "secrets")]
4628#[derive(Args, Debug)]
4629pub struct SecretsCmd {
4630    #[arg(long, value_enum, default_value_t = SecretsProviderKind::Github, help = "Secrets provider to use")]
4631    pub provider: SecretsProviderKind,
4632    #[arg(long, help = "GitHub repository override (owner/repo)")]
4633    pub repo: Option<String>,
4634    #[arg(
4635        long,
4636        help = "Provider token override (GitHub token or Cloudflare API token)"
4637    )]
4638    pub token: Option<String>,
4639    #[arg(long, help = "Cloudflare account ID override")]
4640    pub account_id: Option<String>,
4641    #[arg(
4642        long = "environment",
4643        alias = "env",
4644        default_value = "xbp-dev",
4645        help = "Environment to sync (default: xbp-dev). Nested services are scoped automatically, e.g. xbp-dev-web."
4646    )]
4647    pub environment: String,
4648    #[arg(
4649        long,
4650        help = "Service name from .xbp/xbp.yaml. If omitted, XBP resolves it from the current directory or prompts when ambiguous."
4651    )]
4652    pub service: Option<String>,
4653    #[command(subcommand)]
4654    pub command: Option<SecretsSubCommand>,
4655}
4656
4657#[cfg(feature = "secrets")]
4658#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
4659pub enum SecretsProviderKind {
4660    Github,
4661    Cloudflare,
4662    Railway,
4663    Vercel,
4664}
4665
4666#[cfg(feature = "secrets")]
4667#[derive(Subcommand, Debug)]
4668pub enum SecretsSubCommand {
4669    /// List available secrets providers
4670    #[command(alias = "ls", alias = "list-providers")]
4671    Providers,
4672    /// List local env vars from the preferred env file
4673    List(ListCmd),
4674    /// Push local env vars to the secrets provider (GitHub)
4675    Push(PushCmd),
4676    /// Pull secrets from the provider into .env.local
4677    Pull(PullCmd),
4678    /// Generate .env.default from source code inspection
4679    GenerateDefault(GenerateDefaultCmd),
4680    /// Generate .env.example with categories and defaults
4681    GenerateExample(GenerateExampleCmd),
4682    /// Compare local env with remote (GitHub) variables
4683    Diff,
4684    /// Verify that all required env vars are available locally
4685    Verify,
4686    /// Check connectivity, token scope, and repo access for secrets
4687    #[command(name = "diag", alias = "doctor")]
4688    Diag,
4689    /// Manage Cloudflare secrets stores
4690    Stores(SecretsStoresCmd),
4691    /// Manage Cloudflare secrets in a store
4692    Secrets(CloudflareSecretsCmd),
4693    /// Inspect Cloudflare quota usage
4694    Quota(SecretsQuotaCmd),
4695    /// Show secrets command usage
4696    #[command(name = "usage")]
4697    Usage,
4698}
4699
4700#[cfg(feature = "secrets")]
4701#[derive(Args, Debug)]
4702pub struct ListCmd {
4703    #[arg(long, help = "Env file to list (.env.local, .env, .env.default)")]
4704    pub file: Option<String>,
4705    #[arg(long, help = "Output format: plain (default) or json")]
4706    pub format: Option<String>,
4707}
4708
4709#[cfg(feature = "secrets")]
4710#[derive(Args, Debug)]
4711pub struct PushCmd {
4712    #[arg(long, help = "Path to env file (default: .env.local/.env)")]
4713    pub file: Option<String>,
4714    #[arg(
4715        long = "dev-vars-file",
4716        help = "Path to Cloudflare .dev.vars file. Defaults to .dev.vars when it exists."
4717    )]
4718    pub dev_vars_file: Option<String>,
4719    #[arg(
4720        long,
4721        help = "Skip the Cloudflare .dev.vars GitHub environment sync lane"
4722    )]
4723    pub skip_dev_vars: bool,
4724    #[arg(
4725        long,
4726        help = "Force overwrite existing GitHub Actions environment variables"
4727    )]
4728    pub force: bool,
4729    #[arg(long, help = "Show what would be pushed without making changes")]
4730    pub dry_run: bool,
4731}
4732
4733#[cfg(feature = "secrets")]
4734#[derive(Args, Debug)]
4735pub struct PullCmd {
4736    #[arg(long, help = "Output file path (default: .env.local)")]
4737    pub output: Option<String>,
4738    #[arg(
4739        long = "dev-vars-output",
4740        help = "Output path for Cloudflare .dev.vars. Defaults to .dev.vars when it already exists."
4741    )]
4742    pub dev_vars_output: Option<String>,
4743    #[arg(
4744        long,
4745        help = "Pull the Cloudflare .dev.vars environment even when .dev.vars does not exist yet"
4746    )]
4747    pub include_dev_vars: bool,
4748    #[arg(
4749        long,
4750        help = "Skip the Cloudflare .dev.vars GitHub environment sync lane"
4751    )]
4752    pub skip_dev_vars: bool,
4753}
4754
4755#[cfg(feature = "secrets")]
4756#[derive(Args, Debug)]
4757pub struct GenerateDefaultCmd {
4758    #[arg(long, help = "Output file path (default: .env.default)")]
4759    pub output: Option<String>,
4760}
4761
4762#[cfg(feature = "secrets")]
4763#[derive(Args, Debug)]
4764pub struct GenerateExampleCmd {
4765    #[arg(long, help = "Output file path (default: .env.example)")]
4766    pub output: Option<String>,
4767    #[arg(long, help = "Remove keys from .env.local not in .env.example")]
4768    pub clean: bool,
4769    #[arg(long, help = "Only include vars matching prefix (repeatable)")]
4770    pub include_prefix: Vec<String>,
4771    #[arg(long, help = "Exclude vars matching prefix (repeatable)")]
4772    pub exclude_prefix: Vec<String>,
4773}
4774
4775#[cfg(feature = "secrets")]
4776#[derive(Args, Debug)]
4777pub struct SecretsStoresCmd {
4778    #[command(subcommand)]
4779    pub command: SecretsStoresSubCommand,
4780}
4781
4782#[cfg(feature = "secrets")]
4783#[derive(Subcommand, Debug)]
4784pub enum SecretsStoresSubCommand {
4785    List(CloudflareSecretsStoreListCmd),
4786    Get(CloudflareSecretsStoreGetCmd),
4787    Create(CloudflareSecretsStoreCreateCmd),
4788    Delete(CloudflareSecretsStoreDeleteCmd),
4789}
4790
4791#[cfg(feature = "secrets")]
4792#[derive(Args, Debug)]
4793pub struct CloudflareSecretsStoreListCmd {}
4794
4795#[cfg(feature = "secrets")]
4796#[derive(Args, Debug)]
4797pub struct CloudflareSecretsStoreGetCmd {
4798    #[arg(long)]
4799    pub store_id: String,
4800}
4801
4802#[cfg(feature = "secrets")]
4803#[derive(Args, Debug)]
4804pub struct CloudflareSecretsStoreCreateCmd {
4805    #[arg(long)]
4806    pub name: String,
4807}
4808
4809#[cfg(feature = "secrets")]
4810#[derive(Args, Debug)]
4811pub struct CloudflareSecretsStoreDeleteCmd {
4812    #[arg(long)]
4813    pub store_id: String,
4814}
4815
4816#[cfg(feature = "secrets")]
4817#[derive(Args, Debug)]
4818pub struct CloudflareSecretsCmd {
4819    #[command(subcommand)]
4820    pub command: CloudflareSecretsSubCommand,
4821}
4822
4823#[cfg(feature = "secrets")]
4824#[derive(Subcommand, Debug)]
4825pub enum CloudflareSecretsSubCommand {
4826    List(CloudflareSecretsListCmd),
4827    Get(CloudflareSecretsGetCmd),
4828    Create(CloudflareSecretsCreateCmd),
4829    Edit(CloudflareSecretsEditCmd),
4830    Delete(CloudflareSecretsDeleteCmd),
4831    #[command(name = "delete-bulk")]
4832    DeleteBulk(CloudflareSecretsBulkDeleteCmd),
4833    Duplicate(CloudflareSecretsDuplicateCmd),
4834}
4835
4836#[cfg(feature = "secrets")]
4837#[derive(Args, Debug)]
4838pub struct CloudflareSecretsListCmd {
4839    #[arg(long)]
4840    pub store_id: String,
4841}
4842
4843#[cfg(feature = "secrets")]
4844#[derive(Args, Debug)]
4845pub struct CloudflareSecretsGetCmd {
4846    #[arg(long)]
4847    pub store_id: String,
4848    #[arg(long)]
4849    pub secret_id: String,
4850}
4851
4852#[cfg(feature = "secrets")]
4853#[derive(Args, Debug)]
4854pub struct CloudflareSecretsCreateCmd {
4855    #[arg(long)]
4856    pub store_id: String,
4857    #[arg(long)]
4858    pub name: String,
4859    #[arg(long)]
4860    pub value: String,
4861    #[arg(long, value_delimiter = ',')]
4862    pub scopes: Vec<String>,
4863    #[arg(long)]
4864    pub comment: Option<String>,
4865}
4866
4867#[cfg(feature = "secrets")]
4868#[derive(Args, Debug)]
4869pub struct CloudflareSecretsEditCmd {
4870    #[arg(long)]
4871    pub store_id: String,
4872    #[arg(long)]
4873    pub secret_id: String,
4874    #[arg(long)]
4875    pub name: Option<String>,
4876    #[arg(long)]
4877    pub value: Option<String>,
4878    #[arg(long, value_delimiter = ',')]
4879    pub scopes: Vec<String>,
4880    #[arg(long)]
4881    pub comment: Option<String>,
4882}
4883
4884#[cfg(feature = "secrets")]
4885#[derive(Args, Debug)]
4886pub struct CloudflareSecretsDeleteCmd {
4887    #[arg(long)]
4888    pub store_id: String,
4889    #[arg(long)]
4890    pub secret_id: String,
4891}
4892
4893#[cfg(feature = "secrets")]
4894#[derive(Args, Debug)]
4895pub struct CloudflareSecretsBulkDeleteCmd {
4896    #[arg(long)]
4897    pub store_id: String,
4898    #[arg(long = "secret-id", required = true)]
4899    pub secret_ids: Vec<String>,
4900}
4901
4902#[cfg(feature = "secrets")]
4903#[derive(Args, Debug)]
4904pub struct CloudflareSecretsDuplicateCmd {
4905    #[arg(long)]
4906    pub store_id: String,
4907    #[arg(long)]
4908    pub secret_id: String,
4909    #[arg(long)]
4910    pub name: String,
4911    #[arg(long, value_delimiter = ',')]
4912    pub scopes: Vec<String>,
4913    #[arg(long)]
4914    pub comment: Option<String>,
4915}
4916
4917#[cfg(feature = "secrets")]
4918#[derive(Args, Debug)]
4919pub struct SecretsQuotaCmd {
4920    #[command(subcommand)]
4921    pub command: SecretsQuotaSubCommand,
4922}
4923
4924#[cfg(feature = "secrets")]
4925#[derive(Subcommand, Debug)]
4926pub enum SecretsQuotaSubCommand {
4927    Get(SecretsQuotaGetCmd),
4928}
4929
4930#[cfg(feature = "secrets")]
4931#[derive(Args, Debug)]
4932pub struct SecretsQuotaGetCmd {}
4933
4934const DNS_HELP_TEMPLATE: &str = crate::cli::help_render::XBP_HELP_TEMPLATE;
4935
4936const DNS_COMMAND_AFTER_HELP: &str = "\
4937Examples:
4938  xbp dns providers
4939  xbp dns zones list --provider cloudflare --account-id acc_123
4940  xbp dns records list --provider cloudflare --zone-id zone_123
4941  xbp dns records create --provider cloudflare --zone-id zone_123 --type A --name api --content 127.0.0.1
4942  xbp dns dnssec get --provider cloudflare --zone-id zone_123
4943  xbp dns settings edit --provider cloudflare --zone-id zone_123 --flatten-all-cnames true
4944
4945Notes:
4946  Start with `xbp dns providers` to see what is implemented today.
4947  Cloudflare auth comes from `--token`, `CLOUDFLARE_API_TOKEN`, `xbp config cloudflare set-key`, or a linked dashboard account after `xbp login` (`xbp config cloudflare login`).";
4948
4949const DNS_ZONES_AFTER_HELP: &str = "\
4950Examples:
4951  xbp dns zones list --provider cloudflare --account-id acc_123
4952  xbp dns zones get --provider cloudflare --zone-id zone_123
4953  xbp dns zones create --provider cloudflare --name example.com --account-id acc_123 --jump-start
4954  xbp dns zones edit --provider cloudflare --zone-id zone_123 --paused true
4955  xbp dns zones delete --provider cloudflare --zone-id zone_123";
4956
4957const DNS_RECORDS_AFTER_HELP: &str = "\
4958Examples:
4959  xbp dns records list --provider cloudflare --zone-id zone_123
4960  xbp dns records get --provider cloudflare --zone-id zone_123 --record-id rec_123
4961  xbp dns records create --provider cloudflare --zone-id zone_123 --type A --name api --content 127.0.0.1
4962  xbp dns records edit --provider cloudflare --zone-id zone_123 --record-id rec_123 --proxied true
4963  xbp dns records import --provider cloudflare --zone-id zone_123 --file zone.txt
4964  xbp dns records export --provider cloudflare --zone-id zone_123 --output zone.txt";
4965
4966const DNS_DNSSEC_AFTER_HELP: &str = "\
4967Examples:
4968  xbp dns dnssec get --provider cloudflare --zone-id zone_123
4969  xbp dns dnssec edit --provider cloudflare --zone-id zone_123 --status active";
4970
4971const DNS_SETTINGS_AFTER_HELP: &str = "\
4972Examples:
4973  xbp dns settings get --provider cloudflare --zone-id zone_123
4974  xbp dns settings edit --provider cloudflare --zone-id zone_123 --flatten-all-cnames true
4975  xbp dns settings edit --provider cloudflare --zone-id zone_123 --nameservers-type custom --nameservers-ns-set 2";
4976
4977const DNS_PROVIDERS_AFTER_HELP: &str = "\
4978Examples:
4979  xbp dns providers
4980
4981What this shows:
4982  Implemented providers are wired into `xbp dns` today.
4983  Planned providers are tracked in the CLI surface but not callable yet.";
4984
4985#[derive(Args, Debug)]
4986#[command(
4987    about = "Manage DNS providers, zones, records, DNSSEC, and provider-level settings",
4988    arg_required_else_help = true,
4989    disable_help_subcommand = true,
4990    help_template = DNS_HELP_TEMPLATE,
4991    after_help = DNS_COMMAND_AFTER_HELP
4992)]
4993pub struct DnsCmd {
4994    #[command(subcommand)]
4995    pub command: DnsSubCommand,
4996}
4997
4998#[derive(Subcommand, Debug)]
4999pub enum DnsSubCommand {
5000    #[command(
5001        alias = "ls",
5002        alias = "list",
5003        about = "List supported DNS providers and current implementation status",
5004        after_help = DNS_PROVIDERS_AFTER_HELP
5005    )]
5006    Providers,
5007    #[command(about = "Inspect and manage DNS zones")]
5008    Zones(DnsZonesCmd),
5009    #[command(about = "List, create, edit, import, export, and batch DNS records")]
5010    Records(DnsRecordsCmd),
5011    #[command(about = "Inspect or edit DNSSEC status for a zone")]
5012    Dnssec(DnssecCmd),
5013    #[command(about = "Inspect or edit provider DNS settings for a zone")]
5014    Settings(DnsSettingsCmd),
5015}
5016
5017#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
5018pub enum DnsProviderKind {
5019    Cloudflare,
5020    Hetzner,
5021    Vercel,
5022    Custom,
5023}
5024
5025#[derive(Args, Debug)]
5026#[command(
5027    about = "Inspect and manage DNS zones",
5028    arg_required_else_help = true,
5029    help_template = DNS_HELP_TEMPLATE,
5030    after_help = DNS_ZONES_AFTER_HELP
5031)]
5032pub struct DnsZonesCmd {
5033    #[command(subcommand)]
5034    pub command: DnsZonesSubCommand,
5035}
5036
5037#[derive(Subcommand, Debug)]
5038pub enum DnsZonesSubCommand {
5039    #[command(about = "List zones for a provider account")]
5040    List(DnsZoneListCmd),
5041    #[command(about = "Fetch one zone by id")]
5042    Get(DnsZoneGetCmd),
5043    #[command(about = "Create a new zone")]
5044    Create(DnsZoneCreateCmd),
5045    #[command(about = "Edit zone-level properties")]
5046    Edit(DnsZoneEditCmd),
5047    #[command(about = "Delete a zone")]
5048    Delete(DnsZoneDeleteCmd),
5049}
5050
5051#[derive(Args, Debug)]
5052pub struct DnsZoneListCmd {
5053    #[arg(long, value_enum, default_value = "cloudflare")]
5054    pub provider: DnsProviderKind,
5055    #[arg(long)]
5056    pub account_id: Option<String>,
5057    #[arg(long)]
5058    pub account_name: Option<String>,
5059    #[arg(long = "account-name-op")]
5060    pub account_name_op: Option<String>,
5061    #[arg(long)]
5062    pub name: Option<String>,
5063    #[arg(long = "name-op")]
5064    pub name_op: Option<String>,
5065    #[arg(long)]
5066    pub status: Option<String>,
5067    #[arg(long = "type", value_delimiter = ',')]
5068    pub zone_types: Vec<String>,
5069    #[arg(long)]
5070    pub r#match: Option<String>,
5071    #[arg(long)]
5072    pub order: Option<String>,
5073    #[arg(long)]
5074    pub direction: Option<String>,
5075    #[arg(long)]
5076    pub page: Option<u64>,
5077    #[arg(long = "per-page")]
5078    pub per_page: Option<u64>,
5079    #[arg(long)]
5080    pub token: Option<String>,
5081}
5082
5083#[derive(Args, Debug)]
5084pub struct DnsZoneGetCmd {
5085    #[arg(long, value_enum, default_value = "cloudflare")]
5086    pub provider: DnsProviderKind,
5087    #[arg(long)]
5088    pub zone_id: String,
5089    #[arg(long)]
5090    pub token: Option<String>,
5091}
5092
5093#[derive(Args, Debug)]
5094pub struct DnsZoneCreateCmd {
5095    #[arg(long, value_enum, default_value = "cloudflare")]
5096    pub provider: DnsProviderKind,
5097    #[arg(long)]
5098    pub name: String,
5099    #[arg(long)]
5100    pub account_id: Option<String>,
5101    #[arg(long)]
5102    pub jump_start: bool,
5103    #[arg(long = "type")]
5104    pub zone_type: Option<String>,
5105    #[arg(long)]
5106    pub token: Option<String>,
5107}
5108
5109#[derive(Args, Debug)]
5110pub struct DnsZoneEditCmd {
5111    #[arg(long, value_enum, default_value = "cloudflare")]
5112    pub provider: DnsProviderKind,
5113    #[arg(long)]
5114    pub zone_id: String,
5115    #[arg(long)]
5116    pub paused: Option<bool>,
5117    #[arg(long = "type")]
5118    pub zone_type: Option<String>,
5119    #[arg(long = "vanity-name-server")]
5120    pub vanity_name_servers: Vec<String>,
5121    #[arg(long)]
5122    pub token: Option<String>,
5123}
5124
5125#[derive(Args, Debug)]
5126pub struct DnsZoneDeleteCmd {
5127    #[arg(long, value_enum, default_value = "cloudflare")]
5128    pub provider: DnsProviderKind,
5129    #[arg(long)]
5130    pub zone_id: String,
5131    #[arg(long)]
5132    pub token: Option<String>,
5133}
5134
5135#[derive(Args, Debug)]
5136#[command(
5137    about = "List, create, edit, import, export, and batch DNS records",
5138    arg_required_else_help = true,
5139    help_template = DNS_HELP_TEMPLATE,
5140    after_help = DNS_RECORDS_AFTER_HELP
5141)]
5142pub struct DnsRecordsCmd {
5143    #[command(subcommand)]
5144    pub command: DnsRecordsSubCommand,
5145}
5146
5147#[derive(Subcommand, Debug)]
5148pub enum DnsRecordsSubCommand {
5149    #[command(about = "List records in a zone")]
5150    List(DnsRecordListCmd),
5151    #[command(about = "Fetch one record by id")]
5152    Get(DnsRecordGetCmd),
5153    #[command(about = "Create a new DNS record")]
5154    Create(DnsRecordCreateCmd),
5155    #[command(about = "Replace a record by id with a full payload")]
5156    Replace(DnsRecordReplaceCmd),
5157    #[command(about = "Patch selected fields on a record")]
5158    Edit(DnsRecordEditCmd),
5159    #[command(about = "Delete a record")]
5160    Delete(DnsRecordDeleteCmd),
5161    #[command(about = "Apply a Cloudflare batch record payload from JSON")]
5162    Batch(DnsRecordBatchCmd),
5163    #[command(about = "Import a BIND-style zone file into a zone")]
5164    Import(DnsRecordImportCmd),
5165    #[command(about = "Export a zone as a BIND-style zone file")]
5166    Export(DnsRecordExportCmd),
5167}
5168
5169#[derive(Args, Debug)]
5170pub struct DnsRecordListCmd {
5171    #[arg(long, value_enum, default_value = "cloudflare")]
5172    pub provider: DnsProviderKind,
5173    #[arg(long)]
5174    pub zone_id: String,
5175    #[arg(long = "type")]
5176    pub record_type: Option<String>,
5177    #[arg(long)]
5178    pub name: Option<String>,
5179    #[arg(long)]
5180    pub page: Option<u64>,
5181    #[arg(long = "per-page")]
5182    pub per_page: Option<u64>,
5183    #[arg(long)]
5184    pub token: Option<String>,
5185}
5186
5187#[derive(Args, Debug)]
5188pub struct DnsRecordGetCmd {
5189    #[arg(long, value_enum, default_value = "cloudflare")]
5190    pub provider: DnsProviderKind,
5191    #[arg(long)]
5192    pub zone_id: String,
5193    #[arg(long)]
5194    pub record_id: String,
5195    #[arg(long)]
5196    pub token: Option<String>,
5197}
5198
5199#[derive(Args, Debug)]
5200pub struct DnsRecordCreateCmd {
5201    #[arg(long, value_enum, default_value = "cloudflare")]
5202    pub provider: DnsProviderKind,
5203    #[arg(long)]
5204    pub zone_id: String,
5205    #[arg(long = "type")]
5206    pub record_type: String,
5207    #[arg(long)]
5208    pub name: String,
5209    #[arg(long)]
5210    pub content: String,
5211    #[arg(long)]
5212    pub ttl: Option<u32>,
5213    #[arg(long)]
5214    pub proxied: Option<bool>,
5215    #[arg(long)]
5216    pub priority: Option<u32>,
5217    #[arg(long)]
5218    pub comment: Option<String>,
5219    #[arg(long = "tag")]
5220    pub tags: Vec<String>,
5221    #[arg(long = "data-json")]
5222    pub data_json: Option<String>,
5223    #[arg(long = "settings-json")]
5224    pub settings_json: Option<String>,
5225    #[arg(long)]
5226    pub token: Option<String>,
5227}
5228
5229#[derive(Args, Debug)]
5230pub struct DnsRecordReplaceCmd {
5231    #[command(flatten)]
5232    pub common: DnsRecordCreateCmd,
5233    #[arg(long)]
5234    pub record_id: String,
5235}
5236
5237#[derive(Args, Debug)]
5238pub struct DnsRecordEditCmd {
5239    #[arg(long, value_enum, default_value = "cloudflare")]
5240    pub provider: DnsProviderKind,
5241    #[arg(long)]
5242    pub zone_id: String,
5243    #[arg(long)]
5244    pub record_id: String,
5245    #[arg(long = "type")]
5246    pub record_type: Option<String>,
5247    #[arg(long)]
5248    pub name: Option<String>,
5249    #[arg(long)]
5250    pub content: Option<String>,
5251    #[arg(long)]
5252    pub ttl: Option<u32>,
5253    #[arg(long)]
5254    pub proxied: Option<bool>,
5255    #[arg(long)]
5256    pub priority: Option<u32>,
5257    #[arg(long)]
5258    pub comment: Option<String>,
5259    #[arg(long = "tag")]
5260    pub tags: Vec<String>,
5261    #[arg(long = "data-json")]
5262    pub data_json: Option<String>,
5263    #[arg(long = "settings-json")]
5264    pub settings_json: Option<String>,
5265    #[arg(long)]
5266    pub token: Option<String>,
5267}
5268
5269#[derive(Args, Debug)]
5270pub struct DnsRecordDeleteCmd {
5271    #[arg(long, value_enum, default_value = "cloudflare")]
5272    pub provider: DnsProviderKind,
5273    #[arg(long)]
5274    pub zone_id: String,
5275    #[arg(long)]
5276    pub record_id: String,
5277    #[arg(long)]
5278    pub token: Option<String>,
5279}
5280
5281#[derive(Args, Debug)]
5282pub struct DnsRecordBatchCmd {
5283    #[arg(long, value_enum, default_value = "cloudflare")]
5284    pub provider: DnsProviderKind,
5285    #[arg(long)]
5286    pub zone_id: String,
5287    #[arg(long)]
5288    pub input: PathBuf,
5289    #[arg(long)]
5290    pub token: Option<String>,
5291}
5292
5293#[derive(Args, Debug)]
5294pub struct DnsRecordImportCmd {
5295    #[arg(long, value_enum, default_value = "cloudflare")]
5296    pub provider: DnsProviderKind,
5297    #[arg(long)]
5298    pub zone_id: String,
5299    #[arg(long)]
5300    pub file: PathBuf,
5301    #[arg(long)]
5302    pub token: Option<String>,
5303}
5304
5305#[derive(Args, Debug)]
5306pub struct DnsRecordExportCmd {
5307    #[arg(long, value_enum, default_value = "cloudflare")]
5308    pub provider: DnsProviderKind,
5309    #[arg(long)]
5310    pub zone_id: String,
5311    #[arg(long)]
5312    pub output: Option<PathBuf>,
5313    #[arg(long)]
5314    pub token: Option<String>,
5315}
5316
5317#[derive(Args, Debug)]
5318#[command(
5319    about = "Inspect or edit DNSSEC status for a zone",
5320    arg_required_else_help = true,
5321    help_template = DNS_HELP_TEMPLATE,
5322    after_help = DNS_DNSSEC_AFTER_HELP
5323)]
5324pub struct DnssecCmd {
5325    #[command(subcommand)]
5326    pub command: DnssecSubCommand,
5327}
5328
5329#[derive(Subcommand, Debug)]
5330pub enum DnssecSubCommand {
5331    #[command(about = "Fetch DNSSEC state for a zone")]
5332    Get(DnssecGetCmd),
5333    #[command(about = "Edit DNSSEC-related flags for a zone")]
5334    Edit(DnssecEditCmd),
5335}
5336
5337#[derive(Args, Debug)]
5338pub struct DnssecGetCmd {
5339    #[arg(long, value_enum, default_value = "cloudflare")]
5340    pub provider: DnsProviderKind,
5341    #[arg(long)]
5342    pub zone_id: String,
5343    #[arg(long)]
5344    pub token: Option<String>,
5345}
5346
5347#[derive(Args, Debug)]
5348pub struct DnssecEditCmd {
5349    #[arg(long, value_enum, default_value = "cloudflare")]
5350    pub provider: DnsProviderKind,
5351    #[arg(long)]
5352    pub zone_id: String,
5353    #[arg(long)]
5354    pub status: Option<String>,
5355    #[arg(long = "dnssec-multi-signer")]
5356    pub dnssec_multi_signer: Option<bool>,
5357    #[arg(long = "dnssec-presigned")]
5358    pub dnssec_presigned: Option<bool>,
5359    #[arg(long = "dnssec-use-nsec3")]
5360    pub dnssec_use_nsec3: Option<bool>,
5361    #[arg(long)]
5362    pub token: Option<String>,
5363}
5364
5365#[derive(Args, Debug)]
5366#[command(
5367    about = "Inspect or edit provider DNS settings for a zone",
5368    arg_required_else_help = true,
5369    help_template = DNS_HELP_TEMPLATE,
5370    after_help = DNS_SETTINGS_AFTER_HELP
5371)]
5372pub struct DnsSettingsCmd {
5373    #[command(subcommand)]
5374    pub command: DnsSettingsSubCommand,
5375}
5376
5377#[derive(Subcommand, Debug)]
5378pub enum DnsSettingsSubCommand {
5379    #[command(about = "Fetch provider DNS settings for a zone")]
5380    Get(DnsSettingsGetCmd),
5381    #[command(about = "Edit provider DNS settings for a zone")]
5382    Edit(DnsSettingsEditCmd),
5383}
5384
5385#[derive(Args, Debug)]
5386pub struct DnsSettingsGetCmd {
5387    #[arg(long, value_enum, default_value = "cloudflare")]
5388    pub provider: DnsProviderKind,
5389    #[arg(long)]
5390    pub zone_id: String,
5391    #[arg(long)]
5392    pub token: Option<String>,
5393}
5394
5395#[derive(Args, Debug)]
5396pub struct DnsSettingsEditCmd {
5397    #[arg(long, value_enum, default_value = "cloudflare")]
5398    pub provider: DnsProviderKind,
5399    #[arg(long)]
5400    pub zone_id: String,
5401    #[arg(long)]
5402    pub flatten_all_cnames: Option<bool>,
5403    #[arg(long)]
5404    pub foundation_dns: Option<bool>,
5405    #[arg(long)]
5406    pub multi_provider: Option<bool>,
5407    #[arg(long)]
5408    pub ns_ttl: Option<u32>,
5409    #[arg(long)]
5410    pub secondary_overrides: Option<bool>,
5411    #[arg(long)]
5412    pub zone_mode: Option<String>,
5413    #[arg(long = "reference-zone-id")]
5414    pub reference_zone_id: Option<String>,
5415    #[arg(long = "nameservers-type")]
5416    pub nameservers_type: Option<String>,
5417    #[arg(long = "nameservers-ns-set")]
5418    pub nameservers_ns_set: Option<u32>,
5419    #[arg(long = "soa-json")]
5420    pub soa_json: Option<String>,
5421    #[arg(long)]
5422    pub token: Option<String>,
5423}
5424
5425#[derive(Args, Debug)]
5426#[command(
5427    arg_required_else_help = true,
5428    disable_help_subcommand = true,
5429    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
5430    after_help = crate::cli::help_render::DOMAINS_AFTER_HELP
5431)]
5432pub struct DomainsCmd {
5433    #[arg(long, value_enum, default_value = "cloudflare")]
5434    pub provider: DomainsProviderKind,
5435    #[arg(long)]
5436    pub account_id: Option<String>,
5437    #[arg(long)]
5438    pub token: Option<String>,
5439    #[command(subcommand)]
5440    pub command: DomainsSubCommand,
5441}
5442
5443#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
5444pub enum DomainsProviderKind {
5445    Cloudflare,
5446}
5447
5448#[derive(Subcommand, Debug)]
5449pub enum DomainsSubCommand {
5450    Search(DomainsSearchCmd),
5451    Check(DomainsCheckCmd),
5452    List(DomainsListCmd),
5453}
5454
5455#[derive(Args, Debug)]
5456pub struct DomainsSearchCmd {
5457    #[arg(long)]
5458    pub query: String,
5459    #[arg(long = "extension")]
5460    pub extensions: Vec<String>,
5461    #[arg(long)]
5462    pub limit: Option<usize>,
5463}
5464
5465#[derive(Args, Debug)]
5466pub struct DomainsCheckCmd {
5467    #[arg(long = "domain", required = true)]
5468    pub domains: Vec<String>,
5469}
5470
5471#[derive(Args, Debug)]
5472pub struct DomainsListCmd {}
5473
5474#[derive(Args, Debug)]
5475pub struct GenerateSystemdCmd {
5476    #[arg(
5477        long,
5478        default_value = "/etc/systemd/system",
5479        help = "Directory where the systemd units are written"
5480    )]
5481    pub output_dir: PathBuf,
5482    #[arg(long, help = "Only generate the unit for this service name")]
5483    pub service: Option<String>,
5484    #[arg(
5485        long,
5486        default_value_t = true,
5487        help = "Also generate the xbp-api systemd unit alongside project/services"
5488    )]
5489    pub api: bool,
5490}
5491
5492#[derive(Args, Debug)]
5493#[command(
5494    help_template = crate::cli::help_render::XBP_HELP_TEMPLATE,
5495    after_help = crate::cli::help_render::DONE_AFTER_HELP
5496)]
5497pub struct DoneCmd {
5498    #[arg(long, help = "Root directory under which to discover git repos")]
5499    pub root: Option<std::path::PathBuf>,
5500    #[arg(
5501        long,
5502        default_value = "24 hours ago",
5503        help = "Git --since value (e.g. '7 days ago')"
5504    )]
5505    pub since: String,
5506    #[arg(short, long, help = "Output Markdown file path")]
5507    pub output: Option<std::path::PathBuf>,
5508    #[arg(long, help = "Skip AI summarization (OpenRouter)")]
5509    pub no_ai: bool,
5510    #[arg(short, long, help = "Discover repos recursively")]
5511    pub recursive: bool,
5512    #[arg(long, help = "Exclude repo by name (repeatable)")]
5513    pub exclude: Vec<String>,
5514}
5515
5516#[derive(Args, Debug)]
5517pub struct FixProcessMonitorJsonCmd {
5518    #[arg(help = "Path to a Cursor process-monitor JSON export")]
5519    pub path: std::path::PathBuf,
5520    #[arg(
5521        long,
5522        help = "Check whether the file needs repair without writing changes"
5523    )]
5524    pub check: bool,
5525    #[arg(
5526        long,
5527        help = "Print repaired JSON to stdout instead of overwriting the file"
5528    )]
5529    pub stdout: bool,
5530}
5531
5532#[derive(Args, Debug)]
5533pub struct CursorCmd {
5534    #[command(subcommand)]
5535    pub command: CursorSubCommand,
5536}
5537
5538#[derive(Subcommand, Debug)]
5539pub enum CursorSubCommand {
5540    #[command(about = "Upload local Cursor file history to the XBP dashboard")]
5541    Ingest {
5542        #[arg(
5543            long,
5544            help = "Scan local Cursor history without uploading to the dashboard"
5545        )]
5546        dry_run: bool,
5547    },
5548}
5549
5550#[cfg(feature = "nordvpn")]
5551#[derive(Args, Debug)]
5552pub struct NordvpnCmd {
5553    #[arg(
5554        trailing_var_arg = true,
5555        allow_hyphen_values = true,
5556        help = "Subcommand or args to pass to nordvpn (e.g. setup, meshnet peer list)"
5557    )]
5558    pub args: Vec<String>,
5559}
5560
5561#[cfg(feature = "kubernetes")]
5562#[derive(Args, Debug)]
5563pub struct KubernetesCmd {
5564    #[command(subcommand)]
5565    pub command: KubernetesSubCommand,
5566}
5567
5568#[cfg(feature = "kubernetes")]
5569#[derive(Args, Debug)]
5570pub struct KubernetesAddonCmd {
5571    #[command(subcommand)]
5572    pub command: KubernetesAddonSubCommand,
5573}
5574
5575#[cfg(feature = "kubernetes")]
5576#[derive(Subcommand, Debug)]
5577pub enum KubernetesAddonSubCommand {
5578    /// Show complete addon status (enabled/disabled) from `microk8s status`
5579    List,
5580    /// Enable a MicroK8s addon
5581    Enable {
5582        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
5583        name: String,
5584    },
5585    /// Disable a MicroK8s addon
5586    Disable {
5587        #[arg(help = "Addon name (e.g. cert-manager, ingress, dashboard)")]
5588        name: String,
5589    },
5590}
5591
5592#[cfg(feature = "kubernetes")]
5593#[derive(Subcommand, Debug)]
5594pub enum KubernetesSubCommand {
5595    /// Validate kubectl, current context, and node readiness
5596    Check {
5597        #[arg(long, help = "Kubeconfig context to target")]
5598        context: Option<String>,
5599        #[arg(
5600            long,
5601            default_value = "default",
5602            help = "Namespace to probe for workload readiness"
5603        )]
5604        namespace: String,
5605        #[arg(long, help = "Skip live cluster calls (tooling check only)")]
5606        offline: bool,
5607    },
5608    /// Generate Deployment/Service/NetworkPolicy YAML
5609    Generate {
5610        #[arg(long, help = "Logical app name (used for resource names)")]
5611        name: String,
5612        #[arg(long, help = "Container image reference")]
5613        image: String,
5614        #[arg(long, default_value_t = 80, help = "Container port for the service")]
5615        port: u16,
5616        #[arg(long, default_value_t = 1, help = "Replica count")]
5617        replicas: u16,
5618        #[arg(
5619            long,
5620            default_value = "default",
5621            help = "Namespace for generated resources"
5622        )]
5623        namespace: String,
5624        #[arg(
5625            long,
5626            default_value = "k8s/xbp-manifest.yaml",
5627            help = "Path to write the manifest bundle"
5628        )]
5629        output: String,
5630        #[arg(long, help = "Optional ingress host (creates Ingress when set)")]
5631        host: Option<String>,
5632    },
5633    /// Apply a manifest bundle with kubectl apply -f
5634    Apply {
5635        #[arg(long, help = "Path to manifest file")]
5636        file: String,
5637        #[arg(long, help = "Override kube context")]
5638        context: Option<String>,
5639        #[arg(long, help = "Override namespace")]
5640        namespace: Option<String>,
5641        #[arg(long, help = "Use --dry-run=server")]
5642        dry_run: bool,
5643    },
5644    /// Summarize deployments/services/pods in a namespace
5645    Status {
5646        #[arg(long, default_value = "default", help = "Namespace to summarize")]
5647        namespace: String,
5648        #[arg(long, help = "Override kube context")]
5649        context: Option<String>,
5650    },
5651    /// Manage MicroK8s addons (list, enable, disable)
5652    Addons(KubernetesAddonCmd),
5653    /// Extract Kubernetes Dashboard login token from secret describe output
5654    DashboardToken {
5655        #[arg(
5656            long,
5657            default_value = "kube-system",
5658            help = "Namespace containing the dashboard token secret"
5659        )]
5660        namespace: String,
5661        #[arg(
5662            long,
5663            default_value = "microk8s-dashboard-token",
5664            help = "Secret name containing the dashboard login token"
5665        )]
5666        secret: String,
5667        #[arg(long, help = "Override kube context")]
5668        context: Option<String>,
5669    },
5670    /// Print decoded Grafana admin credentials from observability secret
5671    ObservabilityCreds {
5672        #[arg(
5673            long,
5674            default_value = "observability",
5675            help = "Namespace containing Grafana secret"
5676        )]
5677        namespace: String,
5678        #[arg(
5679            long,
5680            default_value = "kube-prom-stack-grafana",
5681            help = "Grafana secret name"
5682        )]
5683        secret: String,
5684        #[arg(long, help = "Override kube context")]
5685        context: Option<String>,
5686    },
5687    /// Create or update a cert-manager Issuer for Let's Encrypt
5688    Issuer {
5689        #[arg(
5690            long,
5691            help = "Email used for Let's Encrypt account registration (required)"
5692        )]
5693        email: String,
5694        #[arg(long, default_value = "letsencrypt", help = "Issuer resource name")]
5695        name: String,
5696        #[arg(
5697            long,
5698            default_value = "default",
5699            help = "Namespace for the Issuer resource"
5700        )]
5701        namespace: String,
5702        #[arg(
5703            long,
5704            default_value = "https://acme-v02.api.letsencrypt.org/directory",
5705            help = "ACME server URL (production by default)"
5706        )]
5707        server: String,
5708        #[arg(
5709            long,
5710            default_value = "letsencrypt-account-key",
5711            help = "Secret used to store the ACME account private key"
5712        )]
5713        private_key_secret: String,
5714        #[arg(
5715            long,
5716            default_value = "nginx",
5717            help = "Ingress class name used for HTTP01 solving"
5718        )]
5719        ingress_class_name: String,
5720        #[arg(long, help = "Override kube context")]
5721        context: Option<String>,
5722        #[arg(long, help = "Use --dry-run=server")]
5723        dry_run: bool,
5724    },
5725}
5726
5727#[cfg(test)]
5728mod tests {
5729    #[cfg(feature = "linear")]
5730    use super::LinearConfigAction;
5731    use super::{
5732        Cli, CloudflareConfigAction, CloudflareJobWorkflow, CloudflareRollout,
5733        CloudflareSubCommand, CloudflaredSubCommand, Commands, DnsProviderKind, DnsSubCommand,
5734        DnsZonesSubCommand, DomainsProviderKind, DomainsSubCommand, GenerateSubCommand,
5735        NetworkFloatingIpSubCommand, NetworkHetznerSubCommand, NetworkHetznerVswitchSubCommand,
5736        NetworkSubCommand, SshCmd, VersionSubCommand, WorktreeWatchSubCommand,
5737    };
5738    #[cfg(feature = "secrets")]
5739    use super::{
5740        CloudflareSecretsSubCommand, SecretsProviderKind, SecretsStoresSubCommand,
5741        SecretsSubCommand,
5742    };
5743    use clap::Parser;
5744    use std::path::PathBuf;
5745
5746    #[test]
5747    fn parses_network_floating_ip_add() {
5748        let cli = Cli::parse_from([
5749            "xbp",
5750            "network",
5751            "floating-ip",
5752            "add",
5753            "--ip",
5754            "1.2.3.4",
5755            "--apply",
5756        ]);
5757
5758        match cli.command {
5759            Some(Commands::Network(network)) => match network.command {
5760                NetworkSubCommand::FloatingIp(fip) => match fip.command {
5761                    NetworkFloatingIpSubCommand::Add { ip, apply, .. } => {
5762                        assert_eq!(ip, "1.2.3.4");
5763                        assert!(apply);
5764                    }
5765                    _ => panic!("expected add subcommand"),
5766                },
5767                _ => panic!("expected floating-ip subcommand"),
5768            },
5769            _ => panic!("expected network command"),
5770        }
5771    }
5772
5773    #[test]
5774    fn parses_generate_config_update() {
5775        let cli = Cli::parse_from(["xbp", "generate", "config", "--update"]);
5776
5777        match cli.command {
5778            Some(Commands::Generate(generate_cmd)) => match generate_cmd.command {
5779                GenerateSubCommand::Config(config_cmd) => assert!(config_cmd.update),
5780                _ => panic!("expected generate config command"),
5781            },
5782            _ => panic!("expected generate command"),
5783        }
5784    }
5785
5786    #[test]
5787    fn parses_commit_command_with_dry_run() {
5788        let cli = Cli::parse_from(["xbp", "commit", "--dry-run", "--scope", "cli"]);
5789
5790        match cli.command {
5791            Some(Commands::Commit(commit_cmd)) => {
5792                assert!(commit_cmd.dry_run);
5793                assert_eq!(commit_cmd.scope.as_deref(), Some("cli"));
5794                assert_eq!(commit_cmd.model, None);
5795            }
5796            _ => panic!("expected commit command"),
5797        }
5798    }
5799
5800    #[cfg(feature = "linear")]
5801    #[test]
5802    fn parses_linear_select_initiative_config_command() {
5803        let cli = Cli::parse_from(["xbp", "config", "linear", "select-initiative"]);
5804
5805        match cli.command {
5806            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
5807                Some(super::ConfigProviderCmd::Linear(linear_cmd)) => {
5808                    assert!(matches!(
5809                        linear_cmd.action,
5810                        LinearConfigAction::SelectInitiative
5811                    ));
5812                }
5813                _ => panic!("expected linear config provider"),
5814            },
5815            _ => panic!("expected config command"),
5816        }
5817    }
5818
5819    #[test]
5820    fn parses_ssh_command_with_cloudflared_and_key_auth() {
5821        let cli = Cli::parse_from([
5822            "xbp",
5823            "ssh",
5824            "--host",
5825            "ssh.internal",
5826            "--username",
5827            "deploy",
5828            "--private-key",
5829            "C:/Users/floris/.ssh/id_ed25519",
5830            "--cloudflared-hostname",
5831            "bastion.example.com",
5832            "--command",
5833            "htop",
5834        ]);
5835
5836        let Some(Commands::Ssh(SshCmd {
5837            ssh_host,
5838            ssh_username,
5839            private_key,
5840            cloudflared_hostname,
5841            command,
5842            ..
5843        })) = cli.command
5844        else {
5845            panic!("expected shell command");
5846        };
5847
5848        assert_eq!(ssh_host.as_deref(), Some("ssh.internal"));
5849        assert_eq!(ssh_username.as_deref(), Some("deploy"));
5850        assert_eq!(
5851            private_key,
5852            Some(PathBuf::from("C:/Users/floris/.ssh/id_ed25519"))
5853        );
5854        assert_eq!(cloudflared_hostname.as_deref(), Some("bastion.example.com"));
5855        assert_eq!(command.as_deref(), Some("htop"));
5856    }
5857
5858    #[test]
5859    fn parses_cloudflared_tcp_command() {
5860        let cli = Cli::parse_from([
5861            "xbp",
5862            "cloudflared",
5863            "tcp",
5864            "--hostname",
5865            "bastion.example.com",
5866            "--listener",
5867            "127.0.0.1:2222",
5868        ]);
5869
5870        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
5871            panic!("expected cloudflared command");
5872        };
5873
5874        match cloudflared_cmd.command {
5875            CloudflaredSubCommand::Tcp(tcp_cmd) => {
5876                assert_eq!(tcp_cmd.hostname.as_deref(), Some("bastion.example.com"));
5877                assert_eq!(tcp_cmd.listener.as_deref(), Some("127.0.0.1:2222"));
5878            }
5879        }
5880    }
5881
5882    #[test]
5883    fn parses_cloudflared_tcp_without_hostname_for_handler_validation() {
5884        let cli = Cli::try_parse_from(["xbp", "cloudflared", "tcp"]).expect("parse");
5885
5886        let Some(Commands::Cloudflared(cloudflared_cmd)) = cli.command else {
5887            panic!("expected cloudflared command");
5888        };
5889
5890        match cloudflared_cmd.command {
5891            CloudflaredSubCommand::Tcp(tcp_cmd) => {
5892                assert_eq!(tcp_cmd.hostname, None);
5893                assert_eq!(tcp_cmd.listener, None);
5894            }
5895        }
5896    }
5897
5898    #[test]
5899    fn parses_cloudflare_doctor_with_app_after_subcommand() {
5900        let cli = Cli::parse_from(["xbp", "cloudflare", "doctor", "--app", "auth"]);
5901
5902        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5903            panic!("expected cloudflare command");
5904        };
5905        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
5906        assert!(matches!(
5907            cloudflare_cmd.command,
5908            CloudflareSubCommand::Doctor(_)
5909        ));
5910    }
5911
5912    #[test]
5913    fn parses_cloudflare_tunnel_run_with_token_and_log_level() {
5914        let cli = Cli::parse_from([
5915            "xbp",
5916            "cloudflare",
5917            "tunnel",
5918            "run",
5919            "--token",
5920            "tunnel-token",
5921            "--log-level",
5922            "debug",
5923        ]);
5924
5925        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5926            panic!("expected cloudflare command");
5927        };
5928
5929        match cloudflare_cmd.command {
5930            CloudflareSubCommand::Tunnel(tunnel_cmd) => match tunnel_cmd.command {
5931                super::CloudflareTunnelSubCommand::Run(run_cmd) => {
5932                    assert_eq!(run_cmd.tunnel, None);
5933                    assert_eq!(run_cmd.token.as_deref(), Some("tunnel-token"));
5934                    assert_eq!(run_cmd.log_level.as_deref(), Some("debug"));
5935                }
5936                _ => panic!("expected tunnel run command"),
5937            },
5938            _ => panic!("expected cloudflare tunnel command"),
5939        }
5940    }
5941
5942    #[test]
5943    fn parses_cloudflare_workflows_instance_command_with_local_flags() {
5944        let cli = Cli::parse_from([
5945            "xbp",
5946            "cloudflare",
5947            "workflows",
5948            "instances",
5949            "send-event",
5950            "my-workflow",
5951            "latest",
5952            "--type",
5953            "my-event",
5954            "--payload",
5955            "{\"key\":\"value\"}",
5956            "--local",
5957            "--port",
5958            "8787",
5959        ]);
5960
5961        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
5962            panic!("expected cloudflare command");
5963        };
5964
5965        match cloudflare_cmd.command {
5966            CloudflareSubCommand::Workflows(workflows_cmd) => match workflows_cmd.command {
5967                super::CloudflareWorkflowsSubCommand::External(args) => assert_eq!(
5968                    args,
5969                    [
5970                        "instances",
5971                        "send-event",
5972                        "my-workflow",
5973                        "latest",
5974                        "--type",
5975                        "my-event",
5976                        "--payload",
5977                        "{\"key\":\"value\"}",
5978                        "--local",
5979                        "--port",
5980                        "8787"
5981                    ]
5982                ),
5983            },
5984            _ => panic!("expected cloudflare workflows command"),
5985        }
5986    }
5987
5988    #[test]
5989    fn parses_cloudflare_env_dev_with_environment_and_port() {
5990        let cli = Cli::parse_from([
5991            "xbp",
5992            "cloudflare",
5993            "env",
5994            "dev",
5995            "--env",
5996            "staging",
5997            "--port",
5998            "8787",
5999        ]);
6000
6001        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
6002            panic!("expected cloudflare command");
6003        };
6004
6005        match cloudflare_cmd.command {
6006            CloudflareSubCommand::Env(env_cmd) => match env_cmd.command {
6007                super::CloudflareEnvSubCommand::External(args) => {
6008                    assert_eq!(args, ["dev", "--env", "staging", "--port", "8787"])
6009                }
6010            },
6011            _ => panic!("expected cloudflare env command"),
6012        }
6013    }
6014
6015    #[test]
6016    fn parses_cloudflare_release_with_domain_guard() {
6017        let cli = Cli::parse_from([
6018            "xbp",
6019            "cloudflare",
6020            "release",
6021            "--app",
6022            "auth",
6023            "--version",
6024            "1.2.3",
6025            "--rollout",
6026            "gradual",
6027            "--domain",
6028            "auth-runtime",
6029        ]);
6030
6031        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
6032            panic!("expected cloudflare command");
6033        };
6034        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
6035        match cloudflare_cmd.command {
6036            CloudflareSubCommand::Release(release_cmd) => {
6037                assert_eq!(release_cmd.version, "1.2.3");
6038                assert_eq!(release_cmd.rollout, CloudflareRollout::Gradual);
6039                assert_eq!(release_cmd.domain.as_deref(), Some("auth-runtime"));
6040            }
6041            _ => panic!("expected release subcommand"),
6042        }
6043    }
6044
6045    #[test]
6046    fn parses_cloudflare_deploy_parity_flags() {
6047        let cli = Cli::parse_from([
6048            "xbp",
6049            "cloudflare",
6050            "deploy",
6051            "--app",
6052            "auth",
6053            "--rollout",
6054            "none",
6055            "--skip-deploy",
6056            "--allow-unchanged-container-image",
6057            "--prune-old-images",
6058            "--keep-image-tag-count",
6059            "7",
6060        ]);
6061
6062        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
6063            panic!("expected cloudflare command");
6064        };
6065        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
6066        match cloudflare_cmd.command {
6067            CloudflareSubCommand::Deploy(deploy_cmd) => {
6068                assert_eq!(deploy_cmd.rollout, CloudflareRollout::None);
6069                assert!(deploy_cmd.skip_deploy);
6070                assert!(deploy_cmd.allow_unchanged_container_image);
6071                assert!(deploy_cmd.prune_old_images);
6072                assert_eq!(deploy_cmd.keep_image_tag_count, Some(7));
6073            }
6074            _ => panic!("expected deploy subcommand"),
6075        }
6076    }
6077
6078    #[test]
6079    fn parses_cloudflare_release_parity_flags() {
6080        let cli = Cli::parse_from([
6081            "xbp",
6082            "cloudflare",
6083            "release",
6084            "--app",
6085            "auth",
6086            "--version",
6087            "1.2.3",
6088            "--rollout",
6089            "none",
6090            "--skip-deploy",
6091            "--allow-unchanged-container-image",
6092            "--prune-old-images",
6093            "--keep-image-tag-count",
6094            "5",
6095        ]);
6096
6097        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
6098            panic!("expected cloudflare command");
6099        };
6100        match cloudflare_cmd.command {
6101            CloudflareSubCommand::Release(release_cmd) => {
6102                assert_eq!(release_cmd.rollout, CloudflareRollout::None);
6103                assert!(release_cmd.skip_deploy);
6104                assert!(release_cmd.allow_unchanged_container_image);
6105                assert!(release_cmd.prune_old_images);
6106                assert_eq!(release_cmd.keep_image_tag_count, Some(5));
6107            }
6108            _ => panic!("expected release subcommand"),
6109        }
6110    }
6111
6112    #[test]
6113    fn parses_cloudflare_jobs_enqueue_with_workflow_payload() {
6114        let cli = Cli::parse_from([
6115            "xbp",
6116            "cloudflare",
6117            "jobs",
6118            "enqueue",
6119            "release",
6120            "--app",
6121            "auth",
6122            "--version",
6123            "1.2.3",
6124            "--rollout",
6125            "gradual",
6126            "--skip-deploy",
6127            "--prune-old-images",
6128        ]);
6129
6130        let Some(Commands::Cloudflare(cloudflare_cmd)) = cli.command else {
6131            panic!("expected cloudflare command");
6132        };
6133        assert_eq!(cloudflare_cmd.app.as_deref(), Some("auth"));
6134        match cloudflare_cmd.command {
6135            CloudflareSubCommand::Jobs(jobs_cmd) => match jobs_cmd.command {
6136                super::CloudflareJobsSubCommand::Enqueue(enqueue_cmd) => {
6137                    assert_eq!(enqueue_cmd.workflow, CloudflareJobWorkflow::Release);
6138                    assert_eq!(enqueue_cmd.version.as_deref(), Some("1.2.3"));
6139                    assert_eq!(enqueue_cmd.rollout, CloudflareRollout::Gradual);
6140                    assert!(enqueue_cmd.skip_deploy);
6141                    assert!(enqueue_cmd.prune_old_images);
6142                }
6143                _ => panic!("expected enqueue subcommand"),
6144            },
6145            _ => panic!("expected jobs subcommand"),
6146        }
6147    }
6148
6149    #[test]
6150    fn parses_worktree_watch_stop_force_command() {
6151        let cli = Cli::parse_from([
6152            "xbp",
6153            "worktree-watch",
6154            "stop",
6155            "--repo",
6156            "C:/Users/floris/Documents/GitHub/xbp",
6157            "--force",
6158        ]);
6159
6160        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
6161            panic!("expected worktree-watch command");
6162        };
6163
6164        match worktree_cmd.command {
6165            WorktreeWatchSubCommand::Stop(stop_cmd) => {
6166                assert_eq!(
6167                    stop_cmd.target.repo,
6168                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub/xbp"))
6169                );
6170                assert!(stop_cmd.force);
6171            }
6172            _ => panic!("expected stop subcommand"),
6173        }
6174    }
6175
6176    #[test]
6177    fn parses_worktree_watch_parent_detach_command() {
6178        let cli = Cli::parse_from([
6179            "xbp",
6180            "worktree-watch",
6181            "start",
6182            "--parent",
6183            "C:/Users/floris/Documents/GitHub",
6184            "--detach",
6185        ]);
6186
6187        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
6188            panic!("expected worktree-watch command");
6189        };
6190
6191        match worktree_cmd.command {
6192            WorktreeWatchSubCommand::Start(start_cmd) => {
6193                assert_eq!(
6194                    start_cmd.target.parent,
6195                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub"))
6196                );
6197                assert!(start_cmd.detach);
6198            }
6199            _ => panic!("expected start subcommand"),
6200        }
6201    }
6202
6203    #[test]
6204    fn parses_worktree_watch_status_repo_activity_command() {
6205        let cli = Cli::parse_from([
6206            "xbp",
6207            "worktree-watch",
6208            "status",
6209            "--repo-activity",
6210            "--stats-gap-minutes",
6211            "30",
6212        ]);
6213
6214        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
6215            panic!("expected worktree-watch command");
6216        };
6217
6218        match worktree_cmd.command {
6219            WorktreeWatchSubCommand::Status(status_cmd) => {
6220                assert!(status_cmd.repo_activity);
6221                assert_eq!(status_cmd.stats_gap_minutes, 30);
6222            }
6223            _ => panic!("expected status subcommand"),
6224        }
6225    }
6226
6227    #[test]
6228    fn parses_worktree_watch_tray_parent_command() {
6229        let cli = Cli::parse_from([
6230            "xbp",
6231            "worktree-watch",
6232            "tray",
6233            "--parent",
6234            "C:/Users/floris/Documents/GitHub",
6235            "--sync-interval-seconds",
6236            "90",
6237        ]);
6238
6239        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
6240            panic!("expected worktree-watch command");
6241        };
6242
6243        match worktree_cmd.command {
6244            WorktreeWatchSubCommand::Tray(tray_cmd) => {
6245                assert_eq!(
6246                    tray_cmd.target.parent,
6247                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub"))
6248                );
6249                assert!(tray_cmd.target.repos.is_empty());
6250                assert_eq!(tray_cmd.sync_interval_seconds, 90);
6251            }
6252            _ => panic!("expected tray subcommand"),
6253        }
6254    }
6255
6256    #[test]
6257    fn parses_worktree_watch_tray_foreground_command() {
6258        let cli = Cli::parse_from([
6259            "xbp",
6260            "worktree-watch",
6261            "tray",
6262            "--foreground",
6263            "--parent",
6264            "C:/Users/floris/Documents/GitHub",
6265        ]);
6266
6267        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
6268            panic!("expected worktree-watch command");
6269        };
6270
6271        match worktree_cmd.command {
6272            WorktreeWatchSubCommand::Tray(tray_cmd) => {
6273                assert!(tray_cmd.foreground);
6274                assert_eq!(
6275                    tray_cmd.target.parent,
6276                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub"))
6277                );
6278            }
6279            _ => panic!("expected tray subcommand"),
6280        }
6281    }
6282
6283    #[test]
6284    fn parses_worktree_watch_tray_repos_command() {
6285        let cli = Cli::parse_from([
6286            "xbp",
6287            "worktree-watch",
6288            "tray",
6289            "--repos",
6290            "C:/src/a",
6291            "C:/src/b",
6292        ]);
6293
6294        let Some(Commands::WorktreeWatch(worktree_cmd)) = cli.command else {
6295            panic!("expected worktree-watch command");
6296        };
6297
6298        match worktree_cmd.command {
6299            WorktreeWatchSubCommand::Tray(tray_cmd) => {
6300                assert_eq!(
6301                    tray_cmd.target.repos,
6302                    vec![PathBuf::from("C:/src/a"), PathBuf::from("C:/src/b")]
6303                );
6304            }
6305            _ => panic!("expected tray subcommand"),
6306        }
6307    }
6308
6309    #[test]
6310    fn parses_version_workspace_publish_run_command() {
6311        let cli = Cli::parse_from([
6312            "xbp",
6313            "version",
6314            "workspace",
6315            "publish",
6316            "run",
6317            "--repo",
6318            "C:/Users/floris/Documents/GitHub/athena",
6319            "--dry-run",
6320            "--from",
6321            "athena-s3",
6322        ]);
6323
6324        let Some(Commands::Version(version_cmd)) = cli.command else {
6325            panic!("expected version command");
6326        };
6327
6328        match version_cmd.command {
6329            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
6330                match workspace_cmd.command {
6331                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
6332                        match publish_cmd.command {
6333                            super::VersionWorkspacePublishSubCommand::Run(run_cmd) => {
6334                                assert_eq!(
6335                                    run_cmd.target.repo,
6336                                    Some(PathBuf::from("C:/Users/floris/Documents/GitHub/athena"))
6337                                );
6338                                assert!(!run_cmd.target.json);
6339                                assert!(run_cmd.dry_run);
6340                                assert_eq!(run_cmd.from.as_deref(), Some("athena-s3"));
6341                                assert!(run_cmd.auto_fix);
6342                            }
6343                            _ => panic!("expected publish run"),
6344                        }
6345                    }
6346                    _ => panic!("expected workspace publish"),
6347                }
6348            }
6349            _ => panic!("expected version workspace command"),
6350        }
6351    }
6352
6353    #[test]
6354    fn parses_version_workspace_publish_plan_with_only_and_include_prereqs() {
6355        let cli = Cli::parse_from([
6356            "xbp",
6357            "version",
6358            "workspace",
6359            "publish",
6360            "plan",
6361            "--repo",
6362            "C:/Users/floris/Documents/GitHub/athena-auth",
6363            "--only",
6364            "athena-auth",
6365            "--include-prereqs",
6366        ]);
6367
6368        let Some(Commands::Version(version_cmd)) = cli.command else {
6369            panic!("expected version command");
6370        };
6371
6372        match version_cmd.command {
6373            Some(super::VersionSubCommand::Workspace(workspace_cmd)) => {
6374                match workspace_cmd.command {
6375                    super::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
6376                        match publish_cmd.command {
6377                            super::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => {
6378                                assert_eq!(
6379                                    plan_cmd.target.repo,
6380                                    Some(PathBuf::from(
6381                                        "C:/Users/floris/Documents/GitHub/athena-auth"
6382                                    ))
6383                                );
6384                                assert_eq!(plan_cmd.only.as_deref(), Some("athena-auth"));
6385                                assert!(plan_cmd.include_prereqs);
6386                            }
6387                            _ => panic!("expected publish plan"),
6388                        }
6389                    }
6390                    _ => panic!("expected workspace publish"),
6391                }
6392            }
6393            _ => panic!("expected version workspace command"),
6394        }
6395    }
6396
6397    #[test]
6398    fn parses_commit_alias_with_push_flag() {
6399        let cli = Cli::parse_from(["xbp", "c", "-p"]);
6400
6401        let Some(Commands::Commit(commit_cmd)) = cli.command else {
6402            panic!("expected commit command");
6403        };
6404
6405        assert!(commit_cmd.push);
6406        assert!(!commit_cmd.dry_run);
6407    }
6408
6409    #[test]
6410    fn parses_global_push_flag() {
6411        let cli = Cli::parse_from(["xbp", "--push", "version", "patch"]);
6412        assert!(cli.push);
6413
6414        let Some(Commands::Version(version_cmd)) = cli.command else {
6415            panic!("expected version command");
6416        };
6417        assert_eq!(version_cmd.target.as_deref(), Some("patch"));
6418    }
6419
6420    #[test]
6421    fn parses_version_push_flag() {
6422        let cli = Cli::parse_from(["xbp", "version", "patch", "--push"]);
6423        assert!(cli.push);
6424
6425        let Some(Commands::Version(version_cmd)) = cli.command else {
6426            panic!("expected version command");
6427        };
6428        assert!(version_cmd.push);
6429    }
6430
6431    #[test]
6432    fn parses_version_bump_push_flag() {
6433        let cli = Cli::parse_from(["xbp", "version", "bump", "--all", "--patch", "-p"]);
6434
6435        let Some(Commands::Version(version_cmd)) = cli.command else {
6436            panic!("expected version command");
6437        };
6438        let Some(VersionSubCommand::Bump(bump_cmd)) = version_cmd.command else {
6439            panic!("expected version bump subcommand");
6440        };
6441
6442        assert!(bump_cmd.push);
6443        assert!(bump_cmd.all);
6444        assert!(bump_cmd.patch);
6445    }
6446
6447    #[test]
6448    fn parses_version_alias_release_alias() {
6449        let cli = Cli::parse_from([
6450            "xbp",
6451            "v",
6452            "r",
6453            "--draft",
6454            "--publish",
6455            "--force",
6456            "--flag",
6457            "nightly",
6458        ]);
6459
6460        let Some(Commands::Version(version_cmd)) = cli.command else {
6461            panic!("expected version command");
6462        };
6463
6464        let Some(super::VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
6465            panic!("expected release subcommand");
6466        };
6467
6468        assert!(release_cmd.draft);
6469        assert!(release_cmd.publish);
6470        assert!(release_cmd.force);
6471        assert_eq!(release_cmd.flag, Some(super::VersionReleaseFlag::Nightly));
6472    }
6473
6474    #[test]
6475    fn parses_version_domain_release_guard_flags() {
6476        let cli = Cli::parse_from([
6477            "xbp",
6478            "version",
6479            "domain",
6480            "release",
6481            "--domain",
6482            "auth-runtime",
6483            "--version",
6484            "2.0.0",
6485            "--deploy",
6486            "--allow-major-jump",
6487            "--allow-cross-domain-version",
6488            "platform",
6489        ]);
6490
6491        let Some(Commands::Version(version_cmd)) = cli.command else {
6492            panic!("expected version command");
6493        };
6494
6495        let Some(super::VersionSubCommand::Domain(domain_cmd)) = version_cmd.command else {
6496            panic!("expected domain command");
6497        };
6498
6499        match domain_cmd.command {
6500            super::VersionDomainSubCommand::Release(release_cmd) => {
6501                assert_eq!(release_cmd.domain, "auth-runtime");
6502                assert_eq!(release_cmd.version.as_deref(), Some("2.0.0"));
6503                assert!(release_cmd.deploy);
6504                assert!(release_cmd.allow_major_jump);
6505                assert_eq!(
6506                    release_cmd.allow_cross_domain_version.as_deref(),
6507                    Some("platform")
6508                );
6509            }
6510            _ => panic!("expected domain release"),
6511        }
6512    }
6513
6514    #[test]
6515    fn parses_publish_command_target_filter() {
6516        let cli = Cli::parse_from([
6517            "xbp",
6518            "publish",
6519            "--allow-dirty",
6520            "--force",
6521            "--include-prereqs",
6522            "--target",
6523            "npm",
6524            "--service",
6525            "web",
6526            "--manifest-path",
6527            "apps/web/package.json",
6528        ]);
6529
6530        let Some(Commands::Publish(publish_cmd)) = cli.command else {
6531            panic!("expected publish command");
6532        };
6533
6534        assert!(publish_cmd.allow_dirty);
6535        assert!(publish_cmd.force);
6536        assert!(publish_cmd.include_prereqs);
6537        assert_eq!(publish_cmd.target.as_deref(), Some("npm"));
6538        assert_eq!(publish_cmd.service.as_deref(), Some("web"));
6539        assert_eq!(
6540            publish_cmd.manifest_path,
6541            Some(PathBuf::from("apps/web/package.json"))
6542        );
6543    }
6544
6545    #[test]
6546    fn parses_npm_setup_release_config_command() {
6547        let cli = Cli::parse_from(["xbp", "config", "npm", "setup-release"]);
6548
6549        let Some(Commands::Config(config_cmd)) = cli.command else {
6550            panic!("expected config command");
6551        };
6552        let Some(super::ConfigProviderCmd::Npm(registry_cmd)) = config_cmd.provider else {
6553            panic!("expected npm config command");
6554        };
6555
6556        assert!(matches!(
6557            registry_cmd.action,
6558            super::RegistryConfigAction::SetupRelease
6559        ));
6560    }
6561
6562    #[test]
6563    fn parses_release_setup_config_command() {
6564        let cli = Cli::parse_from(["xbp", "config", "release", "setup"]);
6565
6566        let Some(Commands::Config(config_cmd)) = cli.command else {
6567            panic!("expected config command");
6568        };
6569        let Some(super::ConfigProviderCmd::Release(release_cmd)) = config_cmd.provider else {
6570            panic!("expected release config command");
6571        };
6572
6573        assert!(matches!(
6574            release_cmd.action,
6575            super::ReleaseConfigAction::Setup
6576        ));
6577    }
6578
6579    #[test]
6580    fn parses_crates_login_config_command() {
6581        let cli = Cli::parse_from(["xbp", "config", "crates", "login"]);
6582
6583        let Some(Commands::Config(config_cmd)) = cli.command else {
6584            panic!("expected config command");
6585        };
6586        let Some(super::ConfigProviderCmd::Crates(crates_cmd)) = config_cmd.provider else {
6587            panic!("expected crates config command");
6588        };
6589
6590        assert!(matches!(
6591            crates_cmd.action,
6592            super::CratesConfigAction::Login { .. }
6593        ));
6594    }
6595
6596    #[test]
6597    fn parses_crates_logout_config_command() {
6598        let cli = Cli::parse_from(["xbp", "config", "crates", "logout"]);
6599
6600        let Some(Commands::Config(config_cmd)) = cli.command else {
6601            panic!("expected config command");
6602        };
6603        let Some(super::ConfigProviderCmd::Crates(crates_cmd)) = config_cmd.provider else {
6604            panic!("expected crates config command");
6605        };
6606
6607        assert!(matches!(
6608            crates_cmd.action,
6609            super::CratesConfigAction::Logout
6610        ));
6611    }
6612
6613    #[test]
6614    fn parses_whoami_command() {
6615        let cli = Cli::parse_from(["xbp", "whoami"]);
6616
6617        assert!(matches!(cli.command, Some(Commands::Whoami)));
6618    }
6619
6620    #[test]
6621    fn parses_update_command_with_flags() {
6622        let cli = Cli::parse_from([
6623            "xbp",
6624            "update",
6625            "--json",
6626            "--install",
6627            "--fail-if-outdated",
6628            "--crate",
6629            "xbp",
6630            "--features",
6631            "secrets,docker",
6632            "--no-default-features",
6633        ]);
6634
6635        let Some(Commands::Update(cmd)) = cli.command else {
6636            panic!("expected update command");
6637        };
6638        assert!(cmd.json);
6639        assert!(cmd.install);
6640        assert!(cmd.fail_if_outdated);
6641        assert_eq!(cmd.crate_name, "xbp");
6642        assert_eq!(cmd.features, vec!["secrets".to_string(), "docker".to_string()]);
6643        assert!(cmd.no_default_features);
6644        assert!(!cmd.all_features);
6645    }
6646
6647    #[test]
6648    fn parses_update_install_all_features_flag() {
6649        let cli = Cli::parse_from(["xbp", "update", "--install", "--all-features"]);
6650        let Some(Commands::Update(cmd)) = cli.command else {
6651            panic!("expected update command");
6652        };
6653        assert!(cmd.install);
6654        assert!(cmd.all_features);
6655        assert!(cmd.features.is_empty());
6656    }
6657
6658    #[test]
6659    fn parses_upgrade_alias_as_update() {
6660        let cli = Cli::parse_from(["xbp", "upgrade"]);
6661        assert!(matches!(cli.command, Some(Commands::Update(_))));
6662    }
6663
6664    #[test]
6665    fn parses_shell_alias_as_ssh_command() {
6666        let cli = Cli::parse_from(["xbp", "shell", "--host", "ssh.internal"]);
6667
6668        let Some(Commands::Ssh(ssh_cmd)) = cli.command else {
6669            panic!("expected ssh command through shell alias");
6670        };
6671
6672        assert_eq!(ssh_cmd.ssh_host.as_deref(), Some("ssh.internal"));
6673    }
6674
6675    #[test]
6676    fn parses_api_request_command() {
6677        let cli = Cli::parse_from([
6678            "xbp",
6679            "api",
6680            "request",
6681            "/api/registry/installers/python-pip",
6682            "--web",
6683            "--method",
6684            "GET",
6685            "--header",
6686            "accept: application/json",
6687        ]);
6688
6689        let Some(Commands::Api(api_cmd)) = cli.command else {
6690            panic!("expected api command");
6691        };
6692
6693        match api_cmd.command {
6694            super::ApiSubCommand::Request(request_cmd) => {
6695                assert_eq!(request_cmd.path, "/api/registry/installers/python-pip");
6696                assert!(request_cmd.target.web);
6697                assert_eq!(request_cmd.method.as_deref(), Some("GET"));
6698                assert_eq!(
6699                    request_cmd.target.header,
6700                    vec!["accept: application/json".to_string()]
6701                );
6702            }
6703            _ => panic!("expected api request subcommand"),
6704        }
6705    }
6706
6707    #[test]
6708    fn parses_api_projects_list_command() {
6709        let cli = Cli::parse_from([
6710            "xbp",
6711            "api",
6712            "projects",
6713            "list",
6714            "--organization-id",
6715            "org_123",
6716        ]);
6717
6718        let Some(Commands::Api(api_cmd)) = cli.command else {
6719            panic!("expected api command");
6720        };
6721
6722        match api_cmd.command {
6723            super::ApiSubCommand::Projects(projects_cmd) => match projects_cmd.command {
6724                super::ApiProjectsSubCommand::List(list_cmd) => {
6725                    assert_eq!(list_cmd.organization_id.as_deref(), Some("org_123"));
6726                }
6727                _ => panic!("expected projects list subcommand"),
6728            },
6729            _ => panic!("expected projects subcommand"),
6730        }
6731    }
6732
6733    #[test]
6734    fn parses_api_routes_create_command() {
6735        let cli = Cli::parse_from([
6736            "xbp",
6737            "api",
6738            "routes",
6739            "create",
6740            "--domain",
6741            "demo.local",
6742            "--target",
6743            "http://127.0.0.1:3000",
6744            "--weighted-target",
6745            "http://127.0.0.1:3001=3",
6746            "--base-url",
6747            "http://127.0.0.1:8080",
6748        ]);
6749
6750        let Some(Commands::Api(api_cmd)) = cli.command else {
6751            panic!("expected api command");
6752        };
6753
6754        match api_cmd.command {
6755            super::ApiSubCommand::Routes(routes_cmd) => match routes_cmd.command {
6756                super::ApiRoutesSubCommand::Create(create_cmd) => {
6757                    assert_eq!(create_cmd.domain, "demo.local");
6758                    assert_eq!(create_cmd.target, vec!["http://127.0.0.1:3000".to_string()]);
6759                    assert_eq!(
6760                        create_cmd.weighted_target,
6761                        vec!["http://127.0.0.1:3001=3".to_string()]
6762                    );
6763                    assert_eq!(
6764                        create_cmd.target_options.base_url.as_deref(),
6765                        Some("http://127.0.0.1:8080")
6766                    );
6767                }
6768                _ => panic!("expected routes create subcommand"),
6769            },
6770            _ => panic!("expected routes subcommand"),
6771        }
6772    }
6773
6774    #[test]
6775    fn parses_hetzner_vswitch_setup_command() {
6776        let cli = Cli::parse_from([
6777            "xbp",
6778            "network",
6779            "hetzner",
6780            "vswitch",
6781            "setup",
6782            "--ip",
6783            "10.0.3.2",
6784            "--vlan-id",
6785            "4000",
6786            "--interface",
6787            "enp0s31f6",
6788            "--apply",
6789        ]);
6790
6791        let Some(Commands::Network(network_cmd)) = cli.command else {
6792            panic!("expected network command");
6793        };
6794
6795        match network_cmd.command {
6796            NetworkSubCommand::Hetzner(hetzner_cmd) => match hetzner_cmd.command {
6797                NetworkHetznerSubCommand::Vswitch(vswitch_cmd) => match vswitch_cmd.command {
6798                    NetworkHetznerVswitchSubCommand::Setup {
6799                        ip,
6800                        cidr,
6801                        interface,
6802                        vlan_id,
6803                        apply,
6804                        ..
6805                    } => {
6806                        assert_eq!(ip, "10.0.3.2");
6807                        assert_eq!(cidr, 24);
6808                        assert_eq!(interface.as_deref(), Some("enp0s31f6"));
6809                        assert_eq!(vlan_id, 4000);
6810                        assert!(apply);
6811                    }
6812                },
6813            },
6814            _ => panic!("expected hetzner subcommand"),
6815        }
6816    }
6817
6818    #[cfg(feature = "secrets")]
6819    #[test]
6820    fn parses_secrets_diag_command() {
6821        let cli = Cli::parse_from(["xbp", "secrets", "diag"]);
6822
6823        match cli.command {
6824            Some(Commands::Secrets(secrets_cmd)) => {
6825                assert!(matches!(secrets_cmd.command, Some(SecretsSubCommand::Diag)));
6826                assert_eq!(secrets_cmd.environment, "xbp-dev");
6827            }
6828            _ => panic!("expected secrets command"),
6829        }
6830    }
6831
6832    #[cfg(feature = "secrets")]
6833    #[test]
6834    fn parses_secrets_environment_override() {
6835        let cli = Cli::parse_from(["xbp", "secrets", "--environment", "xbp-prod", "push"]);
6836
6837        match cli.command {
6838            Some(Commands::Secrets(secrets_cmd)) => {
6839                assert_eq!(secrets_cmd.environment, "xbp-prod");
6840                assert!(matches!(
6841                    secrets_cmd.command,
6842                    Some(SecretsSubCommand::Push(_))
6843                ));
6844            }
6845            _ => panic!("expected secrets command"),
6846        }
6847    }
6848
6849    #[cfg(feature = "secrets")]
6850    #[test]
6851    fn parses_secrets_dev_vars_sync_flags() {
6852        let cli = Cli::parse_from([
6853            "xbp",
6854            "secrets",
6855            "--environment",
6856            "xbp-prod",
6857            "pull",
6858            "--include-dev-vars",
6859            "--dev-vars-output",
6860            ".dev.vars",
6861        ]);
6862
6863        match cli.command {
6864            Some(Commands::Secrets(secrets_cmd)) => {
6865                assert_eq!(secrets_cmd.environment, "xbp-prod");
6866                match secrets_cmd.command {
6867                    Some(SecretsSubCommand::Pull(pull_cmd)) => {
6868                        assert!(pull_cmd.include_dev_vars);
6869                        assert_eq!(pull_cmd.dev_vars_output.as_deref(), Some(".dev.vars"));
6870                    }
6871                    _ => panic!("expected pull command"),
6872                }
6873            }
6874            _ => panic!("expected secrets command"),
6875        }
6876    }
6877
6878    #[test]
6879    fn parses_version_discover_command() {
6880        let cli = Cli::parse_from(["xbp", "version", "discover", "--dry-run"]);
6881
6882        match cli.command {
6883            Some(Commands::Version(version_cmd)) => match version_cmd.command {
6884                Some(super::VersionSubCommand::Discover(discover_cmd)) => {
6885                    assert!(discover_cmd.dry_run);
6886                    assert!(!discover_cmd.no_register);
6887                }
6888                _ => panic!("expected version discover subcommand"),
6889            },
6890            _ => panic!("expected version command"),
6891        }
6892    }
6893
6894    #[cfg(feature = "secrets")]
6895    #[test]
6896    fn parses_secrets_providers_command() {
6897        let cli = Cli::parse_from(["xbp", "secrets", "providers"]);
6898
6899        match cli.command {
6900            Some(Commands::Secrets(secrets_cmd)) => {
6901                assert!(matches!(
6902                    secrets_cmd.command,
6903                    Some(SecretsSubCommand::Providers)
6904                ));
6905                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Github);
6906            }
6907            _ => panic!("expected secrets command"),
6908        }
6909    }
6910
6911    #[cfg(feature = "secrets")]
6912    #[test]
6913    fn parses_cloudflare_secret_store_create() {
6914        let cli = Cli::parse_from([
6915            "xbp",
6916            "secrets",
6917            "--provider",
6918            "cloudflare",
6919            "stores",
6920            "create",
6921            "--name",
6922            "prod",
6923        ]);
6924
6925        match cli.command {
6926            Some(Commands::Secrets(secrets_cmd)) => {
6927                assert_eq!(secrets_cmd.provider, SecretsProviderKind::Cloudflare);
6928                match secrets_cmd.command {
6929                    Some(SecretsSubCommand::Stores(stores_cmd)) => {
6930                        assert!(matches!(
6931                            stores_cmd.command,
6932                            SecretsStoresSubCommand::Create(_)
6933                        ));
6934                    }
6935                    _ => panic!("expected stores subcommand"),
6936                }
6937            }
6938            _ => panic!("expected secrets command"),
6939        }
6940    }
6941
6942    #[cfg(feature = "secrets")]
6943    #[test]
6944    fn parses_cloudflare_secret_duplicate() {
6945        let cli = Cli::parse_from([
6946            "xbp",
6947            "secrets",
6948            "--provider",
6949            "cloudflare",
6950            "secrets",
6951            "duplicate",
6952            "--store-id",
6953            "store_1",
6954            "--secret-id",
6955            "secret_1",
6956            "--name",
6957            "COPY",
6958        ]);
6959
6960        match cli.command {
6961            Some(Commands::Secrets(secrets_cmd)) => match secrets_cmd.command {
6962                Some(SecretsSubCommand::Secrets(secrets_cmd)) => {
6963                    assert!(matches!(
6964                        secrets_cmd.command,
6965                        CloudflareSecretsSubCommand::Duplicate(_)
6966                    ));
6967                }
6968                _ => panic!("expected cloudflare secrets subcommand"),
6969            },
6970            _ => panic!("expected secrets command"),
6971        }
6972    }
6973
6974    #[test]
6975    fn parses_workers_secret_put_from_stdin_command() {
6976        let cli = Cli::parse_from([
6977            "xbp",
6978            "workers",
6979            "secrets",
6980            "--environment",
6981            "production",
6982            "put",
6983            "--name",
6984            "API_KEY",
6985            "--from-stdin",
6986        ]);
6987
6988        let Some(Commands::Workers(workers_cmd)) = cli.command else {
6989            panic!("expected workers command");
6990        };
6991
6992        match workers_cmd.command {
6993            super::WorkersSubCommand::Secrets(secrets_cmd) => {
6994                assert_eq!(
6995                    secrets_cmd.target.environment.as_deref(),
6996                    Some("production")
6997                );
6998                match secrets_cmd.command {
6999                    super::WorkersSecretsSubCommand::Put(put_cmd) => {
7000                        assert_eq!(put_cmd.name, "API_KEY");
7001                        assert!(put_cmd.from_stdin);
7002                        assert_eq!(put_cmd.value, None);
7003                    }
7004                    _ => panic!("expected workers secret put"),
7005                }
7006            }
7007            _ => panic!("expected workers secrets command"),
7008        }
7009    }
7010
7011    #[test]
7012    fn parses_workers_d1_migrations_local_command() {
7013        let cli = Cli::parse_from([
7014            "xbp",
7015            "workers",
7016            "d1",
7017            "migrations",
7018            "apply",
7019            "DB",
7020            "--local",
7021            "--environment",
7022            "preview",
7023        ]);
7024
7025        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7026            panic!("expected workers command");
7027        };
7028
7029        match workers_cmd.command {
7030            super::WorkersSubCommand::D1(d1_cmd) => match d1_cmd.command {
7031                super::WorkersD1SubCommand::Migrations(migrations_cmd) => {
7032                    match migrations_cmd.command {
7033                        super::WorkersD1MigrationsSubCommand::Apply(apply_cmd) => {
7034                            assert_eq!(apply_cmd.database, "DB");
7035                            assert!(apply_cmd.local);
7036                            assert!(!apply_cmd.remote);
7037                            assert_eq!(apply_cmd.target.environment.as_deref(), Some("preview"));
7038                        }
7039                    }
7040                }
7041            },
7042            _ => panic!("expected workers d1 command"),
7043        }
7044    }
7045
7046    #[test]
7047    fn parses_workers_wrangler_passthrough_for_r2_queues_and_secrets_store() {
7048        let cli = Cli::parse_from([
7049            "xbp",
7050            "workers",
7051            "wrangler",
7052            "run",
7053            "--",
7054            "r2",
7055            "bucket",
7056            "cors",
7057            "set",
7058            "r2-bucket",
7059            "--file",
7060            "cors.json",
7061        ]);
7062
7063        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7064            panic!("expected workers command");
7065        };
7066
7067        match workers_cmd.command {
7068            super::WorkersSubCommand::Wrangler(wrangler_cmd) => match wrangler_cmd.command {
7069                super::WorkersWranglerSubCommand::Run(run_cmd) => assert_eq!(
7070                    run_cmd.args,
7071                    [
7072                        "r2",
7073                        "bucket",
7074                        "cors",
7075                        "set",
7076                        "r2-bucket",
7077                        "--file",
7078                        "cors.json"
7079                    ]
7080                ),
7081                _ => panic!("expected Wrangler run command"),
7082            },
7083            _ => panic!("expected workers wrangler command"),
7084        }
7085    }
7086
7087    #[test]
7088    fn parses_workers_deploy_ci_version_upload_command() {
7089        let cli = Cli::parse_from(["xbp", "workers", "deploy", "ci", "--version-upload"]);
7090
7091        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7092            panic!("expected workers command");
7093        };
7094
7095        match workers_cmd.command {
7096            super::WorkersSubCommand::Deploy(deploy_cmd) => match deploy_cmd.command {
7097                super::WorkersDeploySubCommand::Ci(ci_cmd) => {
7098                    assert!(ci_cmd.version_upload);
7099                }
7100                _ => panic!("expected workers deploy ci command"),
7101            },
7102            _ => panic!("expected workers deploy command"),
7103        }
7104    }
7105
7106    #[test]
7107    fn parses_workers_list_alias_command() {
7108        let cli = Cli::parse_from(["xbp", "workers", "ls", "--all"]);
7109
7110        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7111            panic!("expected workers command");
7112        };
7113
7114        match workers_cmd.command {
7115            super::WorkersSubCommand::List(list_cmd) => {
7116                assert!(list_cmd.all);
7117                assert!(!list_cmd.json);
7118            }
7119            _ => panic!("expected workers list command"),
7120        }
7121    }
7122
7123    #[test]
7124    fn parses_workers_logs_follow_and_build_flags() {
7125        let cli = Cli::parse_from([
7126            "xbp",
7127            "workers",
7128            "logs",
7129            "-f",
7130            "--build",
7131            "--failed",
7132            "xbp-production",
7133        ]);
7134
7135        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7136            panic!("expected workers command");
7137        };
7138
7139        match workers_cmd.command {
7140            super::WorkersSubCommand::Logs(logs_cmd) => {
7141                assert!(logs_cmd.follow);
7142                assert!(logs_cmd.build);
7143                assert!(logs_cmd.failed);
7144                assert_eq!(logs_cmd.script_name.as_deref(), Some("xbp-production"));
7145            }
7146            _ => panic!("expected workers logs command"),
7147        }
7148    }
7149
7150    #[test]
7151    fn parses_workers_logs_worker_and_wait_flags() {
7152        let cli = Cli::parse_from([
7153            "xbp",
7154            "workers",
7155            "logs",
7156            "--worker",
7157            "suits-formations",
7158            "--json",
7159            "--wait",
7160            "--wait-seconds",
7161            "60",
7162        ]);
7163
7164        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7165            panic!("expected workers command");
7166        };
7167
7168        match workers_cmd.command {
7169            super::WorkersSubCommand::Logs(logs_cmd) => {
7170                assert_eq!(logs_cmd.target.worker.as_deref(), Some("suits-formations"));
7171                assert!(logs_cmd.json);
7172                assert!(logs_cmd.wait);
7173                assert_eq!(logs_cmd.wait_seconds, 60);
7174            }
7175            _ => panic!("expected workers logs command"),
7176        }
7177    }
7178
7179    #[test]
7180    fn parses_workers_logs_output_and_filter_flags() {
7181        let cli = Cli::parse_from([
7182            "xbp",
7183            "workers",
7184            "logs",
7185            "--build",
7186            "-n",
7187            "200",
7188            "-o",
7189            "build.log",
7190            "-g",
7191            "error",
7192            "--errors-only",
7193            "--list-builds",
7194            "--build-index",
7195            "1",
7196        ]);
7197
7198        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7199            panic!("expected workers command");
7200        };
7201
7202        match workers_cmd.command {
7203            super::WorkersSubCommand::Logs(logs_cmd) => {
7204                assert!(logs_cmd.build);
7205                assert_eq!(logs_cmd.lines, Some(200));
7206                assert_eq!(
7207                    logs_cmd.output.as_deref().and_then(|path| path.to_str()),
7208                    Some("build.log")
7209                );
7210                assert_eq!(logs_cmd.grep.as_deref(), Some("error"));
7211                assert!(logs_cmd.errors_only);
7212                assert!(logs_cmd.list_builds);
7213                assert_eq!(logs_cmd.build_index, Some(1));
7214            }
7215            _ => panic!("expected workers logs command"),
7216        }
7217    }
7218
7219    #[test]
7220    fn parses_workers_list_filter_and_limit_flags() {
7221        let cli = Cli::parse_from([
7222            "xbp", "workers", "list", "--failed", "-n", "5", "--sort", "modified",
7223        ]);
7224
7225        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7226            panic!("expected workers command");
7227        };
7228
7229        match workers_cmd.command {
7230            super::WorkersSubCommand::List(list_cmd) => {
7231                assert!(list_cmd.failed);
7232                assert_eq!(list_cmd.limit, Some(5));
7233                assert_eq!(list_cmd.sort, "modified");
7234            }
7235            _ => panic!("expected workers list command"),
7236        }
7237    }
7238
7239    #[test]
7240    fn parses_worker_alias_command() {
7241        let cli = Cli::parse_from(["xbp", "worker", "env", "--show-values"]);
7242
7243        let Some(Commands::Workers(workers_cmd)) = cli.command else {
7244            panic!("expected workers command through alias");
7245        };
7246
7247        match workers_cmd.command {
7248            super::WorkersSubCommand::Env(env_cmd) => {
7249                assert!(env_cmd.show_values);
7250            }
7251            _ => panic!("expected workers env command"),
7252        }
7253    }
7254
7255    #[test]
7256    fn parses_dns_providers_command() {
7257        let cli = Cli::parse_from(["xbp", "dns", "providers"]);
7258
7259        match cli.command {
7260            Some(Commands::Dns(dns_cmd)) => {
7261                assert!(matches!(dns_cmd.command, DnsSubCommand::Providers));
7262            }
7263            _ => panic!("expected dns command"),
7264        }
7265    }
7266
7267    #[test]
7268    fn dns_zone_list_defaults_provider_to_cloudflare() {
7269        let cli = Cli::parse_from(["xbp", "dns", "zones", "list"]);
7270
7271        let Some(Commands::Dns(dns_cmd)) = cli.command else {
7272            panic!("expected dns command");
7273        };
7274
7275        match dns_cmd.command {
7276            DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
7277                DnsZonesSubCommand::List(list_cmd) => {
7278                    assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
7279                }
7280                _ => panic!("expected zones list command"),
7281            },
7282            _ => panic!("expected zones command"),
7283        }
7284    }
7285
7286    #[test]
7287    fn dns_record_list_defaults_provider_to_cloudflare() {
7288        let cli = Cli::parse_from(["xbp", "dns", "records", "list", "--zone-id", "zone_123"]);
7289
7290        let Some(Commands::Dns(dns_cmd)) = cli.command else {
7291            panic!("expected dns command");
7292        };
7293
7294        match dns_cmd.command {
7295            DnsSubCommand::Records(records_cmd) => match records_cmd.command {
7296                super::DnsRecordsSubCommand::List(list_cmd) => {
7297                    assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
7298                    assert_eq!(list_cmd.zone_id, "zone_123");
7299                }
7300                _ => panic!("expected records list command"),
7301            },
7302            _ => panic!("expected records command"),
7303        }
7304    }
7305
7306    #[test]
7307    fn dns_help_includes_descriptions_and_examples() {
7308        let err = Cli::try_parse_from(["xbp", "dns", "-h"]).expect_err("help");
7309        let rendered = err.to_string();
7310
7311        assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
7312        assert!(rendered.contains("Manage DNS providers, zones, records, DNSSEC, and settings"));
7313        assert!(rendered.contains("List supported DNS providers and current implementation status"));
7314        assert!(rendered.contains("xbp dns records create"));
7315    }
7316
7317    #[test]
7318    fn dns_providers_help_includes_discovery_note() {
7319        let err = Cli::try_parse_from(["xbp", "dns", "providers", "-h"]).expect_err("help");
7320        let rendered = err.to_string();
7321
7322        assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
7323        assert!(rendered.contains("Implemented providers are wired into `xbp dns` today."));
7324    }
7325
7326    #[test]
7327    fn dns_records_without_subcommand_displays_help_screen() {
7328        let err = Cli::try_parse_from(["xbp", "dns", "records"]).expect_err("missing subcommand");
7329        let rendered = err.to_string();
7330
7331        assert!(matches!(
7332            err.kind(),
7333            clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
7334                | clap::error::ErrorKind::MissingSubcommand
7335        ));
7336        assert!(rendered.contains("List, create, edit, import, export, and batch DNS records"));
7337        assert!(rendered.contains("Create a new DNS record"));
7338        assert!(rendered.contains("xbp dns records import"));
7339    }
7340
7341    #[test]
7342    fn parses_dns_zone_list_command() {
7343        let cli = Cli::parse_from([
7344            "xbp",
7345            "dns",
7346            "zones",
7347            "list",
7348            "--provider",
7349            "cloudflare",
7350            "--account-name-op",
7351            "contains",
7352            "--type",
7353            "full,partial",
7354        ]);
7355
7356        match cli.command {
7357            Some(Commands::Dns(dns_cmd)) => match dns_cmd.command {
7358                DnsSubCommand::Zones(zones_cmd) => match zones_cmd.command {
7359                    DnsZonesSubCommand::List(list_cmd) => {
7360                        assert_eq!(list_cmd.provider, DnsProviderKind::Cloudflare);
7361                        assert_eq!(list_cmd.account_name_op.as_deref(), Some("contains"));
7362                        assert_eq!(list_cmd.zone_types, vec!["full", "partial"]);
7363                    }
7364                    _ => panic!("expected dns zones list"),
7365                },
7366                _ => panic!("expected dns zones"),
7367            },
7368            _ => panic!("expected dns command"),
7369        }
7370    }
7371
7372    #[test]
7373    fn parses_domains_search_command() {
7374        let cli = Cli::parse_from([
7375            "xbp",
7376            "domains",
7377            "search",
7378            "--query",
7379            "xbp",
7380            "--extension",
7381            "com",
7382        ]);
7383
7384        match cli.command {
7385            Some(Commands::Domains(domains_cmd)) => {
7386                assert_eq!(domains_cmd.provider, DomainsProviderKind::Cloudflare);
7387                assert!(matches!(domains_cmd.command, DomainsSubCommand::Search(_)));
7388            }
7389            _ => panic!("expected domains command"),
7390        }
7391    }
7392
7393    #[test]
7394    fn parses_cloudflare_config_account_id_command() {
7395        let cli = Cli::parse_from(["xbp", "config", "cloudflare", "set-account-id", "acc_123"]);
7396
7397        match cli.command {
7398            Some(Commands::Config(config_cmd)) => match config_cmd.provider {
7399                Some(super::ConfigProviderCmd::Cloudflare(cloudflare_cmd)) => {
7400                    assert!(matches!(
7401                        cloudflare_cmd.action,
7402                        Some(CloudflareConfigAction::SetAccountId { .. })
7403                    ));
7404                }
7405                _ => panic!("expected cloudflare config provider"),
7406            },
7407            _ => panic!("expected config command"),
7408        }
7409    }
7410
7411    #[test]
7412    fn parses_runners_groups_update_command() {
7413        let cli = Cli::parse_from([
7414            "xbp",
7415            "runners",
7416            "groups",
7417            "update",
7418            "--organization-id",
7419            "org_123",
7420            "--name",
7421            "linux-builders",
7422            "--visibility",
7423            "selected",
7424            "--restricted-to-workflows",
7425        ]);
7426
7427        let Some(Commands::Runners(runners_cmd)) = cli.command else {
7428            panic!("expected runners command");
7429        };
7430
7431        match runners_cmd.command {
7432            super::RunnersSubCommand::Groups(groups_cmd) => match groups_cmd.command {
7433                super::RunnersGroupsSubCommand::Update(update_cmd) => {
7434                    assert_eq!(update_cmd.organization_id, "org_123");
7435                    assert_eq!(update_cmd.name, "linux-builders");
7436                    assert_eq!(update_cmd.visibility.as_deref(), Some("selected"));
7437                    assert!(update_cmd.restricted_to_workflows);
7438                }
7439                _ => panic!("expected runners groups update command"),
7440            },
7441            _ => panic!("expected runners groups command"),
7442        }
7443    }
7444
7445    #[test]
7446    fn parses_api_runners_group_repositories_create_command() {
7447        let cli = Cli::parse_from([
7448            "xbp",
7449            "api",
7450            "runners",
7451            "group-repositories-create",
7452            "group_123",
7453            "--repository-owner",
7454            "xylex-group",
7455            "--repository-name",
7456            "xbp",
7457            "--repository-full-name",
7458            "xylex-group/xbp",
7459            "--is-private",
7460        ]);
7461
7462        let Some(Commands::Api(api_cmd)) = cli.command else {
7463            panic!("expected api command");
7464        };
7465
7466        match api_cmd.command {
7467            super::ApiSubCommand::Runners(runners_cmd) => match runners_cmd.command {
7468                super::ApiRunnersSubCommand::GroupRepositoriesCreate(create_cmd) => {
7469                    assert_eq!(create_cmd.runner_group_id, "group_123");
7470                    assert_eq!(create_cmd.repository_owner, "xylex-group");
7471                    assert_eq!(create_cmd.repository_name, "xbp");
7472                    assert_eq!(create_cmd.repository_full_name, "xylex-group/xbp");
7473                    assert!(create_cmd.is_private);
7474                }
7475                _ => panic!("expected api runners group repositories create command"),
7476            },
7477            _ => panic!("expected api runners command"),
7478        }
7479    }
7480
7481    #[test]
7482    fn parses_runners_host_preflight_command() {
7483        let cli = Cli::parse_from([
7484            "xbp",
7485            "runners",
7486            "hosts",
7487            "preflight",
7488            "--runner-host-id",
7489            "host_123",
7490            "--write-api",
7491        ]);
7492
7493        let Some(Commands::Runners(runners_cmd)) = cli.command else {
7494            panic!("expected runners command");
7495        };
7496
7497        match runners_cmd.command {
7498            super::RunnersSubCommand::Hosts(hosts_cmd) => match hosts_cmd.command {
7499                super::RunnersHostsSubCommand::Preflight(preflight_cmd) => {
7500                    assert_eq!(preflight_cmd.runner_host_id, "host_123");
7501                    assert!(preflight_cmd.write_api);
7502                }
7503                _ => panic!("expected runners hosts preflight command"),
7504            },
7505            _ => panic!("expected runners hosts command"),
7506        }
7507    }
7508
7509    #[test]
7510    fn parses_runners_agent_serve_command() {
7511        let cli = Cli::parse_from([
7512            "xbp",
7513            "runners",
7514            "agent",
7515            "serve",
7516            "--runner-host-id",
7517            "host_123",
7518            "--interval-seconds",
7519            "5",
7520            "--once",
7521        ]);
7522
7523        let Some(Commands::Runners(runners_cmd)) = cli.command else {
7524            panic!("expected runners command");
7525        };
7526
7527        match runners_cmd.command {
7528            super::RunnersSubCommand::Agent(agent_cmd) => match agent_cmd.command {
7529                super::RunnersAgentSubCommand::Serve(serve_cmd) => {
7530                    assert_eq!(serve_cmd.runner_host_id, "host_123");
7531                    assert_eq!(serve_cmd.interval_seconds, 5);
7532                    assert!(serve_cmd.once);
7533                }
7534            },
7535            _ => panic!("expected runners agent command"),
7536        }
7537    }
7538
7539    #[test]
7540    fn parses_api_runners_host_preflight_upsert_command() {
7541        let cli = Cli::parse_from([
7542            "xbp",
7543            "api",
7544            "runners",
7545            "hosts-preflights-upsert",
7546            "--runner-host-id",
7547            "host_123",
7548            "--platform",
7549            "linux",
7550            "--status",
7551            "synced",
7552            "--service-manager",
7553            "systemd",
7554        ]);
7555
7556        let Some(Commands::Api(api_cmd)) = cli.command else {
7557            panic!("expected api command");
7558        };
7559
7560        match api_cmd.command {
7561            super::ApiSubCommand::Runners(runners_cmd) => match runners_cmd.command {
7562                super::ApiRunnersSubCommand::HostsPreflightsUpsert(upsert_cmd) => {
7563                    assert_eq!(upsert_cmd.runner_host_id, "host_123");
7564                    assert_eq!(upsert_cmd.platform, "linux");
7565                    assert_eq!(upsert_cmd.status, "synced");
7566                    assert_eq!(upsert_cmd.service_manager.as_deref(), Some("systemd"));
7567                }
7568                _ => panic!("expected api runners host preflight upsert command"),
7569            },
7570            _ => panic!("expected api runners command"),
7571        }
7572    }
7573
7574    #[cfg(feature = "linear")]
7575    #[test]
7576    fn parses_linear_list_and_todos_sync() {
7577        let cli = Cli::parse_from([
7578            "xbp",
7579            "linear",
7580            "list",
7581            "--team",
7582            "XLX",
7583            "--assignee",
7584            "me",
7585            "--limit",
7586            "10",
7587        ]);
7588        let Some(Commands::Linear(cmd)) = cli.command else {
7589            panic!("expected linear command");
7590        };
7591        match cmd.command {
7592            Some(super::LinearSubCommand::List(list)) => {
7593                assert_eq!(list.team.as_deref(), Some("XLX"));
7594                assert_eq!(list.assignee.as_deref(), Some("me"));
7595                assert_eq!(list.limit, 10);
7596            }
7597            _ => panic!("expected linear list"),
7598        }
7599
7600        let cli = Cli::parse_from([
7601            "xbp",
7602            "todos",
7603            "sync",
7604            "--to",
7605            "linear",
7606            "--dry-run",
7607            "--yes",
7608            "--team",
7609            "XLX",
7610        ]);
7611        let Some(Commands::Todos(cmd)) = cli.command else {
7612            panic!("expected todos command");
7613        };
7614        match cmd.command {
7615            Some(super::TodosSubCommand::Sync(sync)) => {
7616                assert_eq!(sync.to, Some(super::TodosSyncTarget::Linear));
7617                assert!(sync.dry_run);
7618                assert!(sync.yes);
7619                assert_eq!(sync.team.as_deref(), Some("XLX"));
7620            }
7621            _ => panic!("expected todos sync"),
7622        }
7623
7624        let cli = Cli::parse_from([
7625            "xbp",
7626            "issues",
7627            "sync",
7628            "--to",
7629            "linear",
7630            "--dry-run",
7631            "--yes",
7632            "--team",
7633            "XLX",
7634        ]);
7635        let Some(Commands::Issues(cmd)) = cli.command else {
7636            panic!("expected issues command");
7637        };
7638        match cmd.command {
7639            Some(super::TodosSubCommand::Sync(sync)) => {
7640                assert_eq!(sync.to, Some(super::TodosSyncTarget::Linear));
7641                assert!(sync.dry_run);
7642                assert!(sync.yes);
7643                assert_eq!(sync.team.as_deref(), Some("XLX"));
7644            }
7645            _ => panic!("expected issues sync"),
7646        }
7647
7648        let cli = Cli::parse_from(["xbp", "gh", "show", "42"]);
7649        let Some(Commands::Github(cmd)) = cli.command else {
7650            panic!("expected github command");
7651        };
7652        match cmd.command {
7653            Some(super::GithubSubCommand::Show(show)) => {
7654                assert_eq!(show.id, "42");
7655            }
7656            _ => panic!("expected github show"),
7657        }
7658    }
7659
7660    #[cfg(feature = "linear")]
7661    #[test]
7662    fn parses_linear_search_with_tui() {
7663        let cli = Cli::parse_from([
7664            "xbp", "linear", "search", "cache", "--team", "XLX", "--state", "done", "--sort",
7665            "priority", "--tui",
7666        ]);
7667        let Some(Commands::Linear(cmd)) = cli.command else {
7668            panic!("expected linear command");
7669        };
7670        match cmd.command {
7671            Some(super::LinearSubCommand::Search(search)) => {
7672                assert_eq!(search.query, "cache");
7673                assert_eq!(search.team.as_deref(), Some("XLX"));
7674                assert_eq!(search.state.as_deref(), Some("done"));
7675                assert_eq!(search.sort, "priority");
7676                assert!(search.tui);
7677            }
7678            _ => panic!("expected linear search"),
7679        }
7680    }
7681
7682    #[test]
7683    fn parses_singular_issue_search() {
7684        let cli = Cli::parse_from([
7685            "xbp",
7686            "issue",
7687            "search",
7688            "cache",
7689            "--github",
7690            "--owner",
7691            "xylex-group",
7692            "--repo",
7693            "xbp",
7694            "--limit",
7695            "25",
7696        ]);
7697        let Some(Commands::Issues(cmd)) = cli.command else {
7698            panic!("expected issue command");
7699        };
7700        match cmd.command {
7701            Some(super::TodosSubCommand::Search(search)) => {
7702                assert_eq!(search.query, "cache");
7703                assert!(search.github);
7704                assert_eq!(search.owner.as_deref(), Some("xylex-group"));
7705                assert_eq!(search.repo.as_deref(), Some("xbp"));
7706                assert_eq!(search.limit, 25);
7707            }
7708            _ => panic!("expected issue search"),
7709        }
7710    }
7711
7712    #[cfg(not(feature = "linear"))]
7713    #[test]
7714    fn parses_github_show_without_linear_feature() {
7715        let cli = Cli::parse_from(["xbp", "gh", "show", "42"]);
7716        let Some(Commands::Github(cmd)) = cli.command else {
7717            panic!("expected github command");
7718        };
7719        match cmd.command {
7720            Some(super::GithubSubCommand::Show(show)) => {
7721                assert_eq!(show.id, "42");
7722            }
7723            _ => panic!("expected github show"),
7724        }
7725    }
7726}