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