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