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