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