Skip to main content

xbp_cli/cli/
mod.rs

1pub mod app;
2pub mod auto_commit;
3pub mod commands;
4pub mod error;
5pub mod features;
6pub mod handlers;
7pub mod help_render;
8pub mod interactive;
9pub mod mcp_tools_export;
10pub mod router;
11pub mod ui;
12
13pub use handlers::*;
14
15use crate::cli::app::AppContext;
16use crate::cli::error::{CliError, CliResult, ErrorFactory};
17use crate::commands::cli_session::{
18    post_cli_error_report, require_authenticated_cli_session, run_login_status, run_logout,
19    run_whoami, CliErrorReportPayload,
20};
21use crate::commands::curl;
22#[cfg(feature = "openapi-gen")]
23use crate::commands::generate_openapi::{run_generate_openapi, GenerateOpenApiArgs};
24use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
25use crate::commands::redeploy_v2::run_redeploy_v2;
26use crate::commands::{
27    install_package, list_services, open_global_config, pick_service_for_command, run_commit,
28    run_config, run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
29    run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
30    run_config_cloudflare_status, run_config_crates_login, run_config_crates_logout,
31    run_config_discord, run_config_openapi_setup, run_config_openapi_show,
32    run_config_publish_setup, run_config_release_setup, run_config_secret_delete,
33    run_config_secret_set, run_config_secret_show, run_cursor_ingest, run_generate_config,
34    run_github, run_init,
35    run_login, run_migrate_config_file, run_publish_command, run_redeploy, run_redeploy_service,
36    run_service_command, run_service_interactive, run_setup, run_todos, run_update_check,
37    run_version_bump_command, run_version_command, run_version_discover_services,
38    run_version_domain_command, run_version_release_command, run_version_workspace_command,
39    show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
40    PublishCommandOptions, ReleaseLatestPolicy, UpdateCheckOptions, VersionDomainCommand,
41    VersionDomainCommandOptions, VersionDomainDiagnoseOptions, VersionDomainDoctorOptions,
42    VersionDomainInitOptions, VersionDomainReleaseOptions, VersionDomainSyncOptions,
43    VersionReleaseOptions, WorkspacePublishHealOptions, WorkspacePublishPlanOptions,
44    WorkspacePublishRunOptions, WorkspaceVersionCheckOptions, WorkspaceVersionCommand,
45    WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
46};
47#[cfg(feature = "linear")]
48use crate::commands::{run_config_linear_select_initiative, run_linear};
49use crate::commands::{run_diag, run_nginx};
50use crate::config::{resolve_device_identity, sync_versioning_files_registry};
51use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
52use crate::utils::{
53    find_xbp_config_upwards, git_remote_url_from_metadata, parse_github_repo_from_remote_url,
54};
55use chrono::Utc;
56use clap::{error::ErrorKind as ClapErrorKind, Parser};
57use colored::Colorize;
58use commands::Cli;
59use std::env;
60
61pub async fn run() -> CliResult<()> {
62    // Allow `xbp -h -q deploy`: clap treats -h as exclusive help, so strip root
63    // help flags when a search query is present (subcommand help is unchanged).
64    let argv = preprocess_argv_for_help_query(std::env::args().collect());
65    let cli: Cli = match Cli::try_parse_from(argv) {
66        Ok(cli) => cli,
67        Err(err) => {
68            let kind = err.kind();
69            let rendered = err.to_string();
70            if kind == ClapErrorKind::DisplayVersion {
71                help_render::emit_version_info(env!("CARGO_PKG_VERSION"));
72                return Ok(());
73            }
74            if kind == ClapErrorKind::DisplayHelp
75                || kind == ClapErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
76                || rendered.contains("Manage the XBP API server")
77            {
78                ui::configure_color_output();
79                if help_render::is_root_help_text(&rendered) {
80                    crate::commands::print_help().await;
81                } else {
82                    help_render::emit_styled_help(&rendered, help_render::HelpScope::Auto);
83                }
84                return Ok(());
85            }
86            let error = ErrorFactory::clap_parse(err);
87            report_cli_error("cli", &error).await;
88            return Err(error);
89        }
90    };
91
92    if cli.commands {
93        help_render::print_command_catalog();
94        return Ok(());
95    }
96
97    // `xbp -q deploy` / `xbp -h -q deploy` / `xbp --query webhook`
98    if let Some(query) = cli.query.as_ref().map(|q| q.trim()).filter(|q| !q.is_empty()) {
99        crate::commands::help::print_help_query(query);
100        return Ok(());
101    }
102
103    // Keep plain `xbp` aligned with `xbp --help` instead of entering
104    // interactive project selection flow.
105    if should_print_help(&cli) {
106        ui::configure_color_output();
107        crate::commands::print_help().await;
108        return Ok(());
109    }
110
111    let debug: bool = cli.debug;
112    ui::configure_color_output();
113    let command_name = cli
114        .command
115        .as_ref()
116        .map(commands::command_label)
117        .unwrap_or("interactive");
118    ui::print_cli_header(command_name, debug);
119
120    if let Err(e) = init_logger(debug).await {
121        let _ = log_error(
122            "system",
123            "Failed to initialize logger",
124            Some(&e.to_string()),
125        )
126        .await;
127    }
128    if let Err(e) = sync_versioning_files_registry() {
129        let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
130    }
131
132    let background_cursor_trigger =
133        background_cursor_ingest_trigger(cli.command.as_ref()).map(str::to_string);
134    let background_system_inventory_trigger =
135        background_system_inventory_refresh_trigger(cli.command.as_ref()).map(str::to_string);
136    let mut ctx = AppContext::new(debug);
137    let result = router::dispatch(cli, &mut ctx).await;
138
139    if result.is_ok() {
140        if let Some(trigger) = background_cursor_trigger {
141            if let Err(error) =
142                crate::commands::cursor_ingest::maybe_start_background_cursor_ingest(&trigger)
143            {
144                let _ = log_warn(
145                    "cursor",
146                    "Background Cursor ingest could not be started",
147                    Some(&error),
148                )
149                .await;
150            }
151        }
152        if let Some(trigger) = background_system_inventory_trigger {
153            if let Err(error) = crate::commands::system_inventory_refresh::
154                maybe_start_background_system_inventory_refresh(&trigger)
155            {
156                let _ = log_warn(
157                    "codetime",
158                    "Background system inventory refresh could not be started",
159                    Some(&error),
160                )
161                .await;
162            }
163        }
164    } else if let Err(ref error) = result {
165        report_cli_error(command_name, error).await;
166    }
167
168    result
169}
170
171/// Soft-report a CLI failure to xbp.app (D1). Never raises; missing login skips.
172async fn report_cli_error(command: &str, error: &CliError) {
173    let message = redact_sensitive_text(&strip_ansi(&error.to_string()));
174    if message.trim().is_empty() {
175        return;
176    }
177
178    let cwd = env::current_dir().ok();
179    let project_path = cwd
180        .as_ref()
181        .and_then(|dir| find_xbp_config_upwards(dir))
182        .map(|found| found.project_root.display().to_string());
183    let (repository_owner, repository_name) = cwd
184        .as_ref()
185        .and_then(|dir| {
186            let root = find_xbp_config_upwards(dir)
187                .map(|found| found.project_root)
188                .unwrap_or_else(|| dir.clone());
189            git_remote_url_from_metadata(&root, "origin")
190                .ok()
191                .flatten()
192                .and_then(|url| parse_github_repo_from_remote_url(&url))
193        })
194        .map(|(owner, repo)| (Some(owner), Some(repo)))
195        .unwrap_or((None, None));
196
197    let hardware_id = resolve_device_identity()
198        .ok()
199        .map(|device| device.hardware_id);
200    let hostname = env::var("COMPUTERNAME")
201        .or_else(|_| env::var("HOSTNAME"))
202        .ok()
203        .map(|value| value.trim().to_string())
204        .filter(|value| !value.is_empty());
205
206    let payload = CliErrorReportPayload {
207        command: command.to_string(),
208        action: extract_labeled_field(&message, "Action"),
209        kind: extract_error_kind(&message),
210        message: message.clone(),
211        details: extract_labeled_field(&message, "Details")
212            .or_else(|| extract_labeled_field(&message, "Reason")),
213        hint: extract_labeled_field(&message, "Hint"),
214        project_path,
215        cwd: cwd.map(|path| path.display().to_string()),
216        repository_owner,
217        repository_name,
218        cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
219        platform: Some(format!(
220            "{}-{}",
221            std::env::consts::OS,
222            std::env::consts::ARCH
223        )),
224        hostname,
225        hardware_id,
226        occurred_at: Some(Utc::now().to_rfc3339()),
227    };
228
229    let _ = post_cli_error_report(&payload).await;
230}
231
232fn strip_ansi(input: &str) -> String {
233    let bytes = input.as_bytes();
234    let mut out = String::with_capacity(input.len());
235    let mut i = 0;
236    while i < bytes.len() {
237        if bytes[i] == 0x1b {
238            i += 1;
239            if i < bytes.len() && bytes[i] == b'[' {
240                i += 1;
241                while i < bytes.len() {
242                    let b = bytes[i];
243                    i += 1;
244                    if (b'@'..=b'~').contains(&b) {
245                        break;
246                    }
247                }
248            }
249            continue;
250        }
251        out.push(bytes[i] as char);
252        i += 1;
253    }
254    out
255}
256
257fn redact_sensitive_text(input: &str) -> String {
258    // Best-effort: avoid shipping obvious tokens/keys if they appear in stderr.
259    let mut out = input.to_string();
260    for pattern in [
261        r"(?i)ghp_[A-Za-z0-9]{20,}",
262        r"(?i)gho_[A-Za-z0-9]{20,}",
263        r"(?i)github_pat_[A-Za-z0-9_]{20,}",
264        r"(?i)xbp_[A-Za-z0-9_-]{20,}",
265        r"(?i)sk-[A-Za-z0-9]{20,}",
266        r"(?i)Bearer\s+[A-Za-z0-9._\-+/=]{16,}",
267    ] {
268        if let Ok(re) = regex::Regex::new(pattern) {
269            out = re.replace_all(&out, "[redacted]").into_owned();
270        }
271    }
272    if out.len() > 8_000 {
273        out.truncate(8_000);
274        out.push_str("…");
275    }
276    out
277}
278
279fn extract_labeled_field(message: &str, label: &str) -> Option<String> {
280    let prefix = format!("{label}:");
281    for line in message.lines() {
282        let trimmed = line.trim();
283        if let Some(rest) = trimmed.strip_prefix(&prefix).or_else(|| {
284            // colored headers sometimes leave trailing spaces after labels
285            trimmed
286                .split_once(':')
287                .filter(|(head, _)| head.trim().eq_ignore_ascii_case(label))
288                .map(|(_, rest)| rest)
289        }) {
290            let value = rest.trim();
291            if !value.is_empty() {
292                return Some(value.to_string());
293            }
294        }
295    }
296    None
297}
298
299fn extract_error_kind(message: &str) -> Option<String> {
300    let trimmed = message.trim_start();
301    if let Some(rest) = trimmed.strip_prefix('[') {
302        if let Some(end) = rest.find(']') {
303            let kind = rest[..end].trim();
304            if !kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
305                return Some(kind.to_string());
306            }
307        }
308    }
309    // SECRETS / VERSION-style banners
310    message
311        .lines()
312        .next()
313        .and_then(|line| line.split_whitespace().next())
314        .map(|word| word.trim().to_ascii_uppercase())
315        .filter(|word| {
316            word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && word.len() <= 24
317        })
318}
319
320pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
321    if let Err(e) = run_init(debug).await {
322        let _ = log_error("init", "Init failed", Some(&e)).await;
323        return Err(e.into());
324    }
325    Ok(())
326}
327
328pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
329    if cmd.push {
330        crate::cli::auto_commit::set_explicit_push(true);
331    }
332
333    let args = CommitArgs {
334        dry_run: cmd.dry_run,
335        push: crate::cli::auto_commit::explicit_push_requested(),
336        no_ai: cmd.no_ai,
337        model: cmd.model,
338        scope: cmd.scope,
339    };
340
341    match run_commit(args).await {
342        Ok(CommitRunOutcome::Completed) | Ok(CommitRunOutcome::NothingToCommit) => Ok(()),
343        Err(error) => Err(map_commit_error(error)),
344    }
345}
346
347pub(super) async fn handle_push() -> CliResult<()> {
348    match crate::commands::push::run_push().await {
349        Ok(()) => Ok(()),
350        Err(error) => Err(ErrorFactory::operation(
351            "push",
352            "sync and push current branch",
353            error,
354            Some(
355                "If rebase conflicts remain, resolve them, run `git rebase --continue`, then `xbp push` again. Dirty WIP is stashed as `xbp auto-rebase: temporary stash`.",
356            ),
357        )),
358    }
359}
360
361pub(super) async fn handle_deploy(cmd: commands::DeployCmd, debug: bool) -> CliResult<()> {
362    use crate::commands::deploy_engine::{
363        resolve_deploy_invocation, run_deploy, DeployRequest,
364    };
365    use xbp_deploy::{DeployFlags, DeployMode};
366
367    // Exactly one mode: clap ArgGroup enforces mutual exclusion when multiple set.
368    let mode_count = [
369        cmd.plan,
370        cmd.run,
371        cmd.verify,
372        cmd.status,
373        cmd.history,
374        cmd.promote.is_some(),
375    ]
376    .into_iter()
377    .filter(|v| *v)
378    .count();
379    if mode_count > 1 {
380        return Err(ErrorFactory::validation(
381            "deploy",
382            "exactly one of --plan, --run, --verify, --status, --history, --promote is allowed",
383            Some("Example: `xbp deploy` (interactive) or `xbp deploy athena --env production --plan`"),
384        ));
385    }
386
387    let mode_explicit = mode_count > 0;
388    let mode = if cmd.history {
389        DeployMode::History
390    } else if cmd.promote.is_some() {
391        DeployMode::Promote
392    } else if cmd.verify {
393        DeployMode::Verify
394    } else if cmd.status {
395        DeployMode::Status
396    } else if cmd.run {
397        DeployMode::Run
398    } else {
399        DeployMode::Plan
400    };
401
402    // Load project config early so interactive target/mode/env pickers can list services.
403    let (_project_root, project_config) =
404        match crate::commands::service::load_xbp_config_with_root().await {
405            Ok(v) => v,
406            Err(e) => {
407                return Err(ErrorFactory::operation(
408                    "deploy",
409                    "load project config",
410                    e,
411                    Some("Run `xbp init` or `xbp version discover` inside a project."),
412                ));
413            }
414        };
415
416    let resolved = match resolve_deploy_invocation(
417        &project_config,
418        cmd.target,
419        cmd.env,
420        mode,
421        mode_explicit,
422        cmd.yes,
423        cmd.json || cmd.output.is_some(),
424    ) {
425        Ok(v) => v,
426        Err(e) => {
427            return Err(ErrorFactory::operation(
428                "deploy",
429                "resolve deploy target",
430                e,
431                Some("Pass a service name / group / `all`, or run in a TTY for the interactive picker."),
432            ));
433        }
434    };
435
436    if matches!(resolved.mode, DeployMode::Promote) && cmd.promote.is_none() {
437        return Err(ErrorFactory::validation(
438            "deploy",
439            "promote mode requires --promote <TAG>",
440            Some("Example: `xbp deploy athena --promote stable --dry-run`"),
441        ));
442    }
443
444    let request = DeployRequest {
445        target: resolved.target,
446        env: resolved.env,
447        mode: resolved.mode,
448        flags: DeployFlags {
449            dry_run: cmd.dry_run,
450            skip_oci: cmd.skip_oci,
451            skip_apply: cmd.skip_apply,
452            skip_rollout: cmd.skip_rollout,
453            skip_health: cmd.skip_health,
454            yes: cmd.yes,
455            json: cmd.json,
456            namespace: cmd.namespace,
457            context: cmd.context,
458            promote_tag: cmd.promote,
459            history_limit: cmd.limit,
460            output: cmd.output,
461        },
462        debug,
463    };
464
465    let target_label = request.target.clone();
466    let env_label = request.env.clone().unwrap_or_else(|| "default".to_string());
467    let mode_label = format!("{:?}", request.mode).to_ascii_lowercase();
468
469    match run_deploy(request).await {
470        Ok(()) => {
471            if let Ok((root, cfg)) = crate::commands::service::load_xbp_config_with_root().await {
472                crate::commands::discord_notify::notify_discord_for_project(
473                    &root,
474                    Some(&cfg),
475                    None,
476                    crate::commands::discord_notify::deploy_notification(
477                        &target_label,
478                        &env_label,
479                        &mode_label,
480                        crate::commands::discord_notify::DiscordStatus::Success,
481                        "Deploy orchestration completed",
482                    ),
483                )
484                .await;
485            }
486            Ok(())
487        }
488        Err(error) => {
489            if let Ok((root, cfg)) = crate::commands::service::load_xbp_config_with_root().await {
490                crate::commands::discord_notify::notify_discord_for_project(
491                    &root,
492                    Some(&cfg),
493                    None,
494                    crate::commands::discord_notify::deploy_notification(
495                        &target_label,
496                        &env_label,
497                        &mode_label,
498                        crate::commands::discord_notify::DiscordStatus::Failure,
499                        &error.to_string(),
500                    ),
501                )
502                .await;
503            }
504            Err(ErrorFactory::operation(
505                "deploy",
506                "orchestrate service deploy",
507                error,
508                Some(
509                    "Missing deploy.envs is configured interactively when you re-run in a TTY (or pass --yes to accept derived defaults). Use `xbp deploy <target> --env <env> --plan` first. Pass --yes for non-interactive apply.",
510                ),
511            ))
512        }
513    }
514}
515
516pub(super) async fn handle_oci(cmd: commands::OciCmd, debug: bool) -> CliResult<()> {
517    match crate::oci::run_oci(cmd, debug).await {
518        Ok(()) => Ok(()),
519        Err(error) => Err(ErrorFactory::operation(
520            "oci",
521            "run OCI registry command",
522            error,
523            Some(
524                "Check image reference, registry auth (GH_TOKEN / xbp config oci), or use --anonymous for public pulls.",
525            ),
526        )),
527    }
528}
529
530fn map_commit_error(error: CommitError) -> CliError {
531    match error {
532        CommitError::PushFailed {
533            summary,
534            commit_sha,
535        } => {
536            let details = if let Some(commit_sha) = commit_sha {
537                format!("Created local commit {commit_sha} but push failed: {summary}")
538            } else {
539                format!("Push failed: {summary}")
540            };
541            ErrorFactory::operation(
542                "commit",
543                "push changes",
544                details,
545                commit_push_hint(&summary),
546            )
547        }
548        CommitError::TerminalSessionsBlocked(message) => ErrorFactory::validation(
549            "commit",
550            message,
551            Some("Add `/terminals` to `.gitignore` and run `git rm -r --cached terminals/`."),
552        ),
553        CommitError::Operation(message) => ErrorFactory::operation(
554            "commit",
555            "create conventional commit",
556            message,
557            Some("Run `xbp commit --dry-run` after saving changes."),
558        ),
559    }
560}
561
562fn commit_push_hint(summary: &str) -> Option<&'static str> {
563    let lowered = summary.to_ascii_lowercase();
564    if lowered.contains("unstaged changes")
565        || lowered.contains("uncommitted changes")
566        || lowered.contains("please commit or stash")
567        || lowered.contains("temporary stash")
568    {
569        Some(
570            "XBP stashes dirty WIP for auto-rebase. If push still failed: `git stash list`, restore the `xbp auto-rebase` stash if needed, then `git pull --rebase` and `git push`.",
571        )
572    } else if lowered.contains("auto-rebase failed") || lowered.contains("conflict") {
573        Some("Resolve rebase conflicts, run `git rebase --continue`, then `git push`.")
574    } else if lowered.contains("behind") || lowered.contains("non-fast-forward") {
575        Some("XBP should auto-rebase; if it did not, run `git pull --rebase` then `git push`.")
576    } else {
577        Some("Verify GitHub auth and branch permissions, then push again.")
578    }
579}
580
581pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
582    let options = PublishCommandOptions {
583        dry_run: cmd.dry_run,
584        allow_dirty: cmd.allow_dirty,
585        force: cmd.force,
586        include_prereqs: cmd.include_prereqs,
587        auto_fix: true,
588        target: cmd.target,
589        service: cmd.service,
590        manifest_path: cmd.manifest_path,
591        expected_version: None,
592    };
593
594    if let Err(e) = run_publish_command(options).await {
595        let _ = log_error("publish", "Publish command failed", Some(&e)).await;
596        return Err(ErrorFactory::operation(
597            "publish",
598            "run publish workflow",
599            e,
600            Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
601        ));
602    }
603
604    Ok(())
605}
606
607pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
608    if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
609        let _ = log_error("setup", "Setup failed", Some(&e)).await;
610        return Err(ErrorFactory::operation(
611            "setup",
612            "setup environment",
613            e,
614            Some("Run with `--debug` for command-level output."),
615        ));
616    }
617    Ok(())
618}
619
620pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
621    if let Some(name) = service_name {
622        if let Err(e) = ui::with_loader(
623            &format!("Redeploying service `{}`", name),
624            run_redeploy_service(&name, debug),
625        )
626        .await
627        {
628            let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
629            return Err(ErrorFactory::operation(
630                "redeploy",
631                &format!("redeploy service `{}`", name),
632                e,
633                Some("Verify service name with `xbp services`."),
634            ));
635        }
636    } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
637        let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
638        return Err(ErrorFactory::operation(
639            "redeploy",
640            "redeploy project",
641            e,
642            Some("Try `xbp redeploy <service>` for scoped retries."),
643        ));
644    }
645    Ok(())
646}
647
648pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
649    let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
650    match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
651        Ok(()) => Ok(()),
652        Err(e) => {
653            let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
654            Err(e.into())
655        }
656    }
657}
658
659pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
660    let commands::ConfigCmd {
661        project,
662        no_open,
663        provider,
664    } = cmd;
665
666    if provider.is_some() && (project || no_open) {
667        return Err(ErrorFactory::validation(
668            "config",
669            "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
670            Some("Run either provider key management OR project/global config actions."),
671        ));
672    }
673
674    if let Some(provider_cmd) = provider {
675        match provider_cmd {
676            commands::ConfigProviderCmd::MigrateConfigFile(subcmd) => {
677                run_migrate_config_file(
678                    &subcmd.format,
679                    subcmd.from.as_deref(),
680                    subcmd.output.as_deref(),
681                )
682                .map_err(|error| {
683                    ErrorFactory::operation("config", "migrate config file", error, None)
684                })?;
685            }
686            commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
687                commands::ConfigSecretAction::SetKey { key } => {
688                    if let Err(e) = run_config_secret_set("openrouter", key).await {
689                        let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
690                        return Err(ErrorFactory::operation(
691                            "config",
692                            "set OpenRouter key",
693                            e,
694                            Some("Run `xbp config openrouter show` to confirm key state."),
695                        ));
696                    }
697                }
698                commands::ConfigSecretAction::DeleteKey => {
699                    if let Err(e) = run_config_secret_delete("openrouter").await {
700                        let _ =
701                            log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
702                        return Err(ErrorFactory::operation(
703                            "config",
704                            "delete OpenRouter key",
705                            e,
706                            Some("Use `xbp config openrouter show` to verify removal."),
707                        ));
708                    }
709                }
710                commands::ConfigSecretAction::Show { raw } => {
711                    if let Err(e) = run_config_secret_show("openrouter", raw).await {
712                        let _ =
713                            log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
714                        return Err(ErrorFactory::operation(
715                            "config",
716                            "show OpenRouter key",
717                            e,
718                            None,
719                        ));
720                    }
721                }
722            },
723            commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
724                commands::ConfigSecretAction::SetKey { key } => {
725                    if let Err(e) = run_config_secret_set("github", key).await {
726                        let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
727                        return Err(ErrorFactory::operation(
728                            "config",
729                            "set GitHub token",
730                            e,
731                            Some("Use a token with repo scope for private repos."),
732                        ));
733                    }
734                }
735                commands::ConfigSecretAction::DeleteKey => {
736                    if let Err(e) = run_config_secret_delete("github").await {
737                        let _ =
738                            log_error("config", "Failed to delete GitHub token", Some(&e)).await;
739                        return Err(ErrorFactory::operation(
740                            "config",
741                            "delete GitHub token",
742                            e,
743                            None,
744                        ));
745                    }
746                }
747                commands::ConfigSecretAction::Show { raw } => {
748                    if let Err(e) = run_config_secret_show("github", raw).await {
749                        let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
750                        return Err(ErrorFactory::operation(
751                            "config",
752                            "show GitHub token",
753                            e,
754                            None,
755                        ));
756                    }
757                }
758            },
759            commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
760                None => {
761                    if let Err(e) = run_config_cloudflare_setup().await {
762                        let _ =
763                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
764                        return Err(ErrorFactory::operation(
765                            "config",
766                            "configure Cloudflare",
767                            e,
768                            Some(
769                                "Run `xbp config cloudflare status` to inspect credential sources.",
770                            ),
771                        ));
772                    }
773                }
774                Some(commands::CloudflareConfigAction::SetKey { key }) => {
775                    if let Err(e) = run_config_secret_set("cloudflare", key).await {
776                        let _ =
777                            log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
778                        return Err(ErrorFactory::operation(
779                            "config",
780                            "set Cloudflare token",
781                            e,
782                            Some("Use a Cloudflare API token with Secrets Store and DNS permissions."),
783                        ));
784                    }
785                }
786                Some(commands::CloudflareConfigAction::DeleteKey) => {
787                    if let Err(e) = run_config_secret_delete("cloudflare").await {
788                        let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
789                            .await;
790                        return Err(ErrorFactory::operation(
791                            "config",
792                            "delete Cloudflare token",
793                            e,
794                            None,
795                        ));
796                    }
797                }
798                Some(commands::CloudflareConfigAction::ShowKey { raw }) => {
799                    if let Err(e) = run_config_secret_show("cloudflare", raw).await {
800                        let _ =
801                            log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
802                        return Err(ErrorFactory::operation(
803                            "config",
804                            "show Cloudflare token",
805                            e,
806                            None,
807                        ));
808                    }
809                }
810                Some(commands::CloudflareConfigAction::SetAccountId { account_id }) => {
811                    if let Err(e) = run_config_cloudflare_account_set(account_id).await {
812                        let _ =
813                            log_error("config", "Failed to set Cloudflare account ID", Some(&e))
814                                .await;
815                        return Err(ErrorFactory::operation(
816                            "config",
817                            "set Cloudflare account ID",
818                            e,
819                            None,
820                        ));
821                    }
822                }
823                Some(commands::CloudflareConfigAction::DeleteAccountId) => {
824                    if let Err(e) = run_config_cloudflare_account_delete().await {
825                        let _ =
826                            log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
827                                .await;
828                        return Err(ErrorFactory::operation(
829                            "config",
830                            "delete Cloudflare account ID",
831                            e,
832                            None,
833                        ));
834                    }
835                }
836                Some(commands::CloudflareConfigAction::ShowAccountId { raw }) => {
837                    if let Err(e) = run_config_cloudflare_account_show(raw).await {
838                        let _ =
839                            log_error("config", "Failed to show Cloudflare account ID", Some(&e))
840                                .await;
841                        return Err(ErrorFactory::operation(
842                            "config",
843                            "show Cloudflare account ID",
844                            e,
845                            None,
846                        ));
847                    }
848                }
849                Some(commands::CloudflareConfigAction::Login) => {
850                    if let Err(e) = run_config_cloudflare_login().await {
851                        let _ = log_error("config", "Failed to link Cloudflare", Some(&e)).await;
852                        return Err(ErrorFactory::operation(
853                            "config",
854                            "link Cloudflare",
855                            e,
856                            None,
857                        ));
858                    }
859                }
860                Some(commands::CloudflareConfigAction::Status) => {
861                    if let Err(e) = run_config_cloudflare_status().await {
862                        let _ =
863                            log_error("config", "Failed to show Cloudflare status", Some(&e)).await;
864                        return Err(ErrorFactory::operation(
865                            "config",
866                            "show Cloudflare status",
867                            e,
868                            None,
869                        ));
870                    }
871                }
872                Some(commands::CloudflareConfigAction::Setup) => {
873                    if let Err(e) = run_config_cloudflare_setup().await {
874                        let _ =
875                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
876                        return Err(ErrorFactory::operation(
877                            "config",
878                            "configure Cloudflare",
879                            e,
880                            None,
881                        ));
882                    }
883                }
884            },
885            #[cfg(feature = "linear")]
886            commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
887                commands::LinearConfigAction::SetKey { key } => {
888                    if let Err(e) = run_config_secret_set("linear", key).await {
889                        let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
890                        return Err(ErrorFactory::operation(
891                            "config",
892                            "set Linear API key",
893                            e,
894                            Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
895                        ));
896                    }
897                }
898                commands::LinearConfigAction::DeleteKey => {
899                    if let Err(e) = run_config_secret_delete("linear").await {
900                        let _ =
901                            log_error("config", "Failed to delete Linear API key", Some(&e)).await;
902                        return Err(ErrorFactory::operation(
903                            "config",
904                            "delete Linear API key",
905                            e,
906                            None,
907                        ));
908                    }
909                }
910                commands::LinearConfigAction::Show { raw } => {
911                    if let Err(e) = run_config_secret_show("linear", raw).await {
912                        let _ =
913                            log_error("config", "Failed to show Linear API key", Some(&e)).await;
914                        return Err(ErrorFactory::operation(
915                            "config",
916                            "show Linear API key",
917                            e,
918                            None,
919                        ));
920                    }
921                }
922                commands::LinearConfigAction::SelectInitiative => {
923                    if let Err(e) = run_config_linear_select_initiative().await {
924                        let _ = log_error(
925                            "config",
926                            "Failed to select repo Linear initiative",
927                            Some(&e),
928                        )
929                        .await;
930                        return Err(ErrorFactory::operation(
931                            "config",
932                            "select repo Linear initiative",
933                            e,
934                            Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
935                        ));
936                    }
937                }
938            },
939            commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
940                commands::RegistryConfigAction::SetKey { key } => {
941                    if let Err(e) = run_config_secret_set("npm", key).await {
942                        let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
943                        return Err(ErrorFactory::operation(
944                            "config",
945                            "set npm token",
946                            e,
947                            Some("Use a valid npm automation or granular publish token."),
948                        ));
949                    }
950                }
951                commands::RegistryConfigAction::DeleteKey => {
952                    if let Err(e) = run_config_secret_delete("npm").await {
953                        let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
954                        return Err(ErrorFactory::operation(
955                            "config",
956                            "delete npm token",
957                            e,
958                            None,
959                        ));
960                    }
961                }
962                commands::RegistryConfigAction::Show { raw } => {
963                    if let Err(e) = run_config_secret_show("npm", raw).await {
964                        let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
965                        return Err(ErrorFactory::operation("config", "show npm token", e, None));
966                    }
967                }
968                commands::RegistryConfigAction::SetupRelease => {
969                    if let Err(e) = run_config_publish_setup("npm").await {
970                        let _ =
971                            log_error("config", "Failed to configure npm publish", Some(&e)).await;
972                        return Err(ErrorFactory::operation(
973                            "config",
974                            "configure npm publish",
975                            e,
976                            Some("Run this inside the target XBP project and ensure the package manifest exists."),
977                        ));
978                    }
979                }
980            },
981            commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
982                commands::CratesConfigAction::SetKey { key } => {
983                    if let Err(e) = run_config_secret_set("crates", key).await {
984                        let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
985                        return Err(ErrorFactory::operation(
986                            "config",
987                            "set crates token",
988                            e,
989                            Some("Use a crates.io API token or rely on CARGO_REGISTRY_TOKEN. Then run `xbp config crates login` if you also want Cargo's local credentials file updated."),
990                        ));
991                    }
992                }
993                commands::CratesConfigAction::DeleteKey => {
994                    if let Err(e) = run_config_secret_delete("crates").await {
995                        let _ =
996                            log_error("config", "Failed to delete crates token", Some(&e)).await;
997                        return Err(ErrorFactory::operation(
998                            "config",
999                            "delete crates token",
1000                            e,
1001                            None,
1002                        ));
1003                    }
1004                }
1005                commands::CratesConfigAction::Show { raw } => {
1006                    if let Err(e) = run_config_secret_show("crates", raw).await {
1007                        let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
1008                        return Err(ErrorFactory::operation(
1009                            "config",
1010                            "show crates token",
1011                            e,
1012                            None,
1013                        ));
1014                    }
1015                }
1016                commands::CratesConfigAction::SetupRelease => {
1017                    if let Err(e) = run_config_publish_setup("crates").await {
1018                        let _ = log_error("config", "Failed to configure crates publish", Some(&e))
1019                            .await;
1020                        return Err(ErrorFactory::operation(
1021                            "config",
1022                            "configure crates publish",
1023                            e,
1024                            Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
1025                        ));
1026                    }
1027                }
1028                commands::CratesConfigAction::Login { key } => {
1029                    if let Err(e) = run_config_crates_login(key).await {
1030                        let _ = log_error("config", "Failed to log Cargo into crates.io", Some(&e))
1031                            .await;
1032                        return Err(ErrorFactory::operation(
1033                            "config",
1034                            "log Cargo into crates.io",
1035                            e,
1036                            Some("Store a crates token with `xbp config crates set-key` first, or pass it directly to `xbp config crates login <token>`."),
1037                        ));
1038                    }
1039                }
1040                commands::CratesConfigAction::Logout => {
1041                    if let Err(e) = run_config_crates_logout().await {
1042                        let _ =
1043                            log_error("config", "Failed to log Cargo out of crates.io", Some(&e))
1044                                .await;
1045                        return Err(ErrorFactory::operation(
1046                            "config",
1047                            "log Cargo out of crates.io",
1048                            e,
1049                            Some("This only clears Cargo's local credentials. Use `xbp config crates delete-key` if you also want to remove the token from global XBP config."),
1050                        ));
1051                    }
1052                }
1053            },
1054            commands::ConfigProviderCmd::Release(subcmd) => match subcmd.action {
1055                commands::ReleaseConfigAction::Setup => {
1056                    if let Err(e) = run_config_release_setup().await {
1057                        let _ =
1058                            log_error("config", "Failed to configure release settings", Some(&e))
1059                                .await;
1060                        return Err(ErrorFactory::operation(
1061                            "config",
1062                            "configure release settings",
1063                            e,
1064                            Some("Run this inside the target XBP project to update .xbp/xbp.toml (or existing project config)."),
1065                        ));
1066                    }
1067                }
1068            },
1069            commands::ConfigProviderCmd::Discord(subcmd) => {
1070                if let Err(e) = run_config_discord(subcmd.action).await {
1071                    let _ = log_error("config", "Failed to configure Discord webhook", Some(&e))
1072                        .await;
1073                    return Err(ErrorFactory::operation(
1074                        "config",
1075                        "configure Discord webhook notifications",
1076                        e,
1077                        Some(
1078                            "Run `xbp config discord setup` inside a project (writes .xbp/xbp.toml by default), or use `--global` for ~/.xbp/config.yaml. Set DISCORD_WEBHOOK_URL as an alternative.",
1079                        ),
1080                    ));
1081                }
1082            }
1083            commands::ConfigProviderCmd::Openapi(subcmd) => match subcmd.action {
1084                None | Some(commands::OpenapiConfigAction::Setup) => {
1085                    if let Err(e) = run_config_openapi_setup().await {
1086                        let _ =
1087                            log_error("config", "Failed to configure OpenAPI generation", Some(&e))
1088                                .await;
1089                        return Err(ErrorFactory::operation(
1090                            "config",
1091                            "configure OpenAPI generation",
1092                            e,
1093                            Some("Run this inside the target XBP project to update .xbp/xbp.toml (or existing project config)."),
1094                        ));
1095                    }
1096                }
1097                Some(commands::OpenapiConfigAction::Show) => {
1098                    if let Err(e) = run_config_openapi_show().await {
1099                        let _ =
1100                            log_error("config", "Failed to show OpenAPI config", Some(&e)).await;
1101                        return Err(ErrorFactory::operation(
1102                            "config",
1103                            "show OpenAPI config",
1104                            e,
1105                            Some("Run this inside the target XBP project."),
1106                        ));
1107                    }
1108                }
1109            },
1110            commands::ConfigProviderCmd::Oci(subcmd) => {
1111                if let Err(e) = crate::oci::run_config_oci(subcmd.action).await {
1112                    let _ = log_error("config", "Failed to configure OCI registry credentials", Some(&e))
1113                        .await;
1114                    return Err(ErrorFactory::operation(
1115                        "config",
1116                        "configure OCI registry credentials",
1117                        e,
1118                        Some(
1119                            "Use `xbp config oci set-registry-token --registry ghcr.io --username USER` (token masked in show).",
1120                        ),
1121                    ));
1122                }
1123            }
1124        }
1125    } else if project {
1126        if let Err(e) = run_config(debug).await {
1127            return Err(ErrorFactory::operation(
1128                "config",
1129                "read project config",
1130                e,
1131                Some("Ensure you're inside an XBP project root."),
1132            ));
1133        }
1134    } else if let Err(e) = open_global_config(no_open).await {
1135        let _ = log_error("config", "Failed to open global config", Some(&e)).await;
1136        return Err(ErrorFactory::operation(
1137            "config",
1138            "open global config",
1139            e,
1140            None,
1141        ));
1142    }
1143    Ok(())
1144}
1145
1146pub(super) async fn handle_install(
1147    package: Option<String>,
1148    list: bool,
1149    force: bool,
1150    debug: bool,
1151) -> CliResult<()> {
1152    if list {
1153        crate::commands::print_install_targets_help();
1154        return Ok(());
1155    }
1156
1157    let Some(package) = package else {
1158        crate::commands::print_install_empty_state();
1159        return Ok(());
1160    };
1161
1162    if package.trim().is_empty() {
1163        crate::commands::print_install_empty_state();
1164        return Ok(());
1165    }
1166
1167    if crate::commands::is_install_listing_request(&package) {
1168        crate::commands::print_install_targets_help();
1169        return Ok(());
1170    }
1171
1172    let install_msg: String = format!("Installing package: {}", package);
1173    let _ = log_info("install", &install_msg, None).await;
1174    match ui::with_loader(
1175        &format!("Installing package `{}`", package),
1176        install_package(&package, force, debug),
1177    )
1178    .await
1179    {
1180        Ok(()) => {
1181            let success_msg = format!("Successfully installed: {}", package);
1182            let _ = log_success("install", &success_msg, None).await;
1183            Ok(())
1184        }
1185        Err(e) => Err(e.into()),
1186    }
1187}
1188
1189pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
1190    let url = cmd
1191        .url
1192        .unwrap_or_else(|| "https://example.com/api".to_string());
1193    if let Err(e) = ui::with_loader(
1194        &format!("Requesting {}", url),
1195        curl::run_curl(&url, cmd.no_timeout, debug),
1196    )
1197    .await
1198    {
1199        let _ = log_error("curl", "Curl command failed", Some(&e)).await;
1200        return Err(ErrorFactory::operation(
1201            "curl",
1202            "execute request",
1203            e,
1204            Some("Double-check the URL and network connectivity."),
1205        ));
1206    }
1207    Ok(())
1208}
1209
1210pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
1211    if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
1212        let _ = log_error("services", "Failed to list services", Some(&e)).await;
1213        return Err(ErrorFactory::operation(
1214            "services",
1215            "list services",
1216            e,
1217            Some("Ensure xbp config is present and valid."),
1218        ));
1219    }
1220    Ok(())
1221}
1222
1223pub(super) async fn handle_service(
1224    command: Option<String>,
1225    service_name: Option<String>,
1226    debug: bool,
1227) -> CliResult<()> {
1228    match (command.as_deref(), service_name.as_deref()) {
1229        // Bare `xbp service` → preview + interactive picker (service then command)
1230        (None, None) => {
1231            if let Err(e) = run_service_interactive(debug).await {
1232                let _ = log_error("service", "Interactive service run failed", Some(&e)).await;
1233                return Err(ErrorFactory::operation(
1234                    "service",
1235                    "interactive picker",
1236                    e,
1237                    None,
1238                ));
1239            }
1240        }
1241        // `xbp service <cmd>` (no name) → pick service that supports the cmd
1242        (Some(cmd), None) if cmd != "help" && cmd != "--help" => {
1243            if let Err(e) = pick_service_for_command(cmd, debug).await {
1244                let _ = log_error(
1245                    "service",
1246                    &format!("Service pick for '{}' failed", cmd),
1247                    Some(&e),
1248                )
1249                .await;
1250                return Err(ErrorFactory::operation(
1251                    "service",
1252                    &format!("pick service for {}", cmd),
1253                    e,
1254                    Some("Pass a service name explicitly, or ensure commands are defined in xbp config."),
1255                ));
1256            }
1257        }
1258        // `xbp service help <name>` or `xbp service --help <name>`
1259        (Some(cmd), Some(name)) if cmd == "help" || cmd == "--help" => {
1260            if let Err(e) = show_service_help(name).await {
1261                let _ = log_error("service", "Failed to show service help", Some(&e)).await;
1262                return Err(ErrorFactory::operation(
1263                    "service",
1264                    "show service help",
1265                    e,
1266                    None,
1267                ));
1268            }
1269        }
1270        // `xbp service <cmd> <name>` — classic direct execution
1271        (Some(cmd), Some(name)) => {
1272            if let Err(e) = run_service_command(cmd, name, debug).await {
1273                let _ = log_error(
1274                    "service",
1275                    &format!("Service command '{}' failed", cmd),
1276                    Some(&e),
1277                )
1278                .await;
1279                return Err(ErrorFactory::operation(
1280                    "service",
1281                    &format!("run `{}` for `{}`", cmd, name),
1282                    e,
1283                    Some("Check available services via `xbp services`."),
1284                ));
1285            }
1286        }
1287        // `xbp service <name>` (no explicit command) — treat as show service info / help
1288        (None, Some(name)) => {
1289            if let Err(e) = show_service_help(name).await {
1290                // fall back to usage if help fails (e.g. unknown service)
1291                let _ = log_warn(
1292                    "service",
1293                    &format!("Could not show help for service '{}': {}", name, e),
1294                    None,
1295                )
1296                .await;
1297                print_service_usage();
1298            }
1299        }
1300        // `xbp service help` (no name)
1301        (Some(_), None) => {
1302            print_service_usage();
1303        }
1304    }
1305    Ok(())
1306}
1307
1308pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
1309    if let Err(e) = run_nginx(cmd, debug).await {
1310        let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
1311        return Err(ErrorFactory::operation(
1312            "nginx",
1313            "execute nginx command",
1314            e.to_string(),
1315            Some("Try `xbp nginx --help` for command syntax."),
1316        ));
1317    }
1318    Ok(())
1319}
1320
1321pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
1322    if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
1323        let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
1324        return Err(ErrorFactory::operation(
1325            "diag",
1326            "run diagnostics",
1327            e.to_string(),
1328            Some("Re-run with `--debug` for more context."),
1329        ));
1330    }
1331    Ok(())
1332}
1333
1334pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
1335    match cmd.command {
1336        commands::GenerateSubCommand::Config(subcmd) => {
1337            let args = GenerateConfigArgs {
1338                force: subcmd.force,
1339                update: subcmd.update,
1340                from_json: subcmd.from_json,
1341            };
1342            if let Err(e) = run_generate_config(args, debug).await {
1343                let _ = log_error(
1344                    "generate-config",
1345                    "Failed to generate project config",
1346                    Some(&e),
1347                )
1348                .await;
1349                return Err(ErrorFactory::operation(
1350                    "generate-config",
1351                    "generate .xbp/xbp.toml",
1352                    e,
1353                    Some("Use --update to refresh an existing config or --force to overwrite it."),
1354                ));
1355            }
1356        }
1357        #[cfg(feature = "openapi-gen")]
1358        commands::GenerateSubCommand::Openapi(subcmd) => {
1359            let args = GenerateOpenApiArgs {
1360                services: subcmd.service,
1361                all: subcmd.all,
1362                aggregate: false,
1363                format: subcmd.format,
1364                output: subcmd.output,
1365                check: subcmd.check,
1366                dry_run: subcmd.dry_run,
1367                strict: subcmd.strict,
1368                no_cache: subcmd.no_cache,
1369                version: None,
1370            };
1371            if let Err(e) = run_generate_openapi(args, debug).await {
1372                let _ = log_error("generate-openapi", "Failed to generate OpenAPI", Some(&e)).await;
1373                return Err(ErrorFactory::operation(
1374                    "generate-openapi",
1375                    "generate OpenAPI documents",
1376                    e,
1377                    Some("Check openapi settings in project config (.xbp/xbp.toml by default) or use --strict for unresolved type details."),
1378                ));
1379            }
1380        }
1381        commands::GenerateSubCommand::Systemd(subcmd) => {
1382            let args = GenerateSystemdArgs {
1383                output_dir: subcmd.output_dir,
1384                service: subcmd.service,
1385                api: subcmd.api,
1386            };
1387            if let Err(e) = run_generate_systemd(args, debug).await {
1388                let _ = log_error(
1389                    "generate-systemd",
1390                    "Failed to generate systemd units",
1391                    Some(&e),
1392                )
1393                .await;
1394                return Err(ErrorFactory::operation(
1395                    "generate-systemd",
1396                    "generate unit files",
1397                    e,
1398                    Some("Use a writable `--output-dir` or run with elevated permissions."),
1399                ));
1400            }
1401        }
1402    }
1403    Ok(())
1404}
1405
1406pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
1407    if let Err(e) = crate::commands::run_done(
1408        cmd.root,
1409        cmd.since,
1410        cmd.output,
1411        cmd.no_ai,
1412        cmd.recursive,
1413        cmd.exclude,
1414    )
1415    .await
1416    {
1417        let _ = log_error("done", "Done command failed", Some(&e)).await;
1418        return Err(e.into());
1419    }
1420    Ok(())
1421}
1422
1423#[cfg(feature = "linear")]
1424pub(super) async fn handle_linear(cmd: commands::LinearIssuesCmd, _debug: bool) -> CliResult<()> {
1425    if let Err(e) = run_linear(cmd).await {
1426        let _ = log_error("linear", "Linear command failed", Some(&e)).await;
1427        return Err(ErrorFactory::operation(
1428            "linear",
1429            "run Linear issues command",
1430            e,
1431            Some("Configure a key with `xbp config linear set-key`."),
1432        ));
1433    }
1434    Ok(())
1435}
1436
1437pub(super) async fn handle_github(cmd: commands::GithubIssuesCmd, _debug: bool) -> CliResult<()> {
1438    if let Err(e) = run_github(cmd).await {
1439        let _ = log_error("github", "GitHub issues command failed", Some(&e)).await;
1440        return Err(ErrorFactory::operation(
1441            "github",
1442            "run GitHub issues command",
1443            e,
1444            Some("Configure a token with `xbp config github set-key` and ensure git origin is a GitHub repo."),
1445        ));
1446    }
1447    Ok(())
1448}
1449
1450pub(super) async fn handle_todos(cmd: commands::TodosCmd, _debug: bool) -> CliResult<()> {
1451    if let Err(e) = run_todos(cmd).await {
1452        let _ = log_error("todos", "Todos command failed", Some(&e)).await;
1453        return Err(ErrorFactory::operation(
1454            "todos",
1455            "run todos scan/sync",
1456            e,
1457            Some("Try `xbp todos scan` first, then `xbp todos sync --to both --dry-run`."),
1458        ));
1459    }
1460    Ok(())
1461}
1462
1463pub(super) async fn handle_issues(cmd: commands::TodosCmd, _debug: bool) -> CliResult<()> {
1464    if let Err(e) = crate::commands::todos::run_issues(cmd).await {
1465        let _ = log_error("issues", "Issues command failed", Some(&e)).await;
1466        return Err(ErrorFactory::operation(
1467            "issues",
1468            "run issues scan/sync",
1469            e,
1470            Some("Try `xbp issues scan` first, then `xbp issues sync --to both --dry-run`."),
1471        ));
1472    }
1473    Ok(())
1474}
1475
1476pub(super) async fn handle_fix_process_monitor_json(
1477    cmd: commands::FixProcessMonitorJsonCmd,
1478    _debug: bool,
1479) -> CliResult<()> {
1480    if let Err(error) =
1481        crate::commands::run_fix_process_monitor_json(cmd.path, cmd.check, cmd.stdout)
1482    {
1483        let _ = log_error(
1484            "fix-process-monitor-json",
1485            "Failed to repair process monitor JSON",
1486            Some(&error),
1487        )
1488        .await;
1489        return Err(error.into());
1490    }
1491    Ok(())
1492}
1493
1494pub(super) async fn handle_cursor(cmd: commands::CursorCmd) -> CliResult<()> {
1495    let result = match cmd.command {
1496        commands::CursorSubCommand::Ingest { dry_run } => run_cursor_ingest(dry_run).await,
1497    };
1498
1499    if let Err(error) = result {
1500        let _ = log_error("cursor", "Cursor command failed", Some(&error)).await;
1501        return Err(error.into());
1502    }
1503    Ok(())
1504}
1505
1506pub(super) async fn handle_login(cmd: commands::LoginCmd) -> CliResult<()> {
1507    let result = match cmd.action {
1508        Some(commands::LoginSubCommand::Status) => run_login_status().await,
1509        Some(commands::LoginSubCommand::Logout) => run_logout().await,
1510        None => run_login().await,
1511    };
1512
1513    if let Err(e) = result {
1514        let _ = log_error("login", "Login command failed", Some(&e)).await;
1515        return Err(e.into());
1516    }
1517
1518    Ok(())
1519}
1520
1521pub(super) async fn handle_whoami() -> CliResult<()> {
1522    if let Err(e) = run_whoami().await {
1523        let _ = log_error("whoami", "Whoami command failed", Some(&e)).await;
1524        return Err(e.into());
1525    }
1526
1527    Ok(())
1528}
1529
1530pub(super) async fn handle_update(cmd: commands::UpdateCmd) -> CliResult<()> {
1531    let options = UpdateCheckOptions {
1532        crate_name: cmd.crate_name,
1533        json: cmd.json,
1534        install: cmd.install,
1535        features: cmd.features,
1536        no_default_features: cmd.no_default_features,
1537        all_features: cmd.all_features,
1538        fail_if_outdated: cmd.fail_if_outdated,
1539    };
1540    if let Err(e) = run_update_check(options).await {
1541        let _ = log_error("update", "Update check failed", Some(&e)).await;
1542        return Err(ErrorFactory::operation(
1543            "update",
1544            "check crates.io for a newer XBP release",
1545            e,
1546            Some(
1547                "Confirm network access to https://crates.io/crates/xbp, or install with `cargo install xbp --locked --features all`.",
1548            ),
1549        ));
1550    }
1551    Ok(())
1552}
1553
1554pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
1555    if let Err(e) = require_authenticated_cli_session().await {
1556        return Err(ErrorFactory::operation(
1557            "version",
1558            "verify CLI login",
1559            e,
1560            Some("Run `xbp login` before using version workflows."),
1561        ));
1562    }
1563
1564    if cmd.push {
1565        crate::cli::auto_commit::set_explicit_push(true);
1566    }
1567
1568    let commands::VersionCmd {
1569        target,
1570        explicit_version,
1571        git,
1572        command,
1573        ..
1574    } = cmd;
1575    let resolved_target = explicit_version.or(target);
1576
1577    if command.is_some() && (resolved_target.is_some() || git) {
1578        return Err(ErrorFactory::validation(
1579            "version",
1580            "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
1581            Some(
1582                "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
1583            ),
1584        ));
1585    }
1586
1587    if let Some(subcommand) = command {
1588        match subcommand {
1589            commands::VersionSubCommand::Release(release_cmd) => {
1590                let options = VersionReleaseOptions {
1591                    explicit_version: release_cmd.version,
1592                    release_flag: release_cmd.flag.map(|flag| flag.as_str().to_string()),
1593                    allow_dirty: release_cmd.allow_dirty,
1594                    title: release_cmd.title,
1595                    notes: release_cmd.notes,
1596                    notes_file: release_cmd.notes_file,
1597                    draft: release_cmd.draft,
1598                    prerelease: release_cmd.prerelease,
1599                    publish: release_cmd.publish,
1600                    force: release_cmd.force,
1601                    dry_run: release_cmd.dry_run,
1602                    force_scope: None,
1603                    latest_policy: match release_cmd.make_latest {
1604                        commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
1605                        commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
1606                        commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
1607                    },
1608                };
1609                if let Err(e) = run_version_release_command(options).await {
1610                    let hint = version_release_error_hint(&e);
1611                    return Err(ErrorFactory::operation(
1612                        "version",
1613                        "release version",
1614                        e,
1615                        hint,
1616                    ));
1617                }
1618                return Ok(());
1619            }
1620            commands::VersionSubCommand::Discover(discover_cmd) => {
1621                if let Err(e) = run_version_discover_services(&discover_cmd).await {
1622                    return Err(ErrorFactory::operation(
1623                        "version",
1624                        "discover and register project services",
1625                        e,
1626                        Some(
1627                            "Run `xbp version discover --dry-run` to preview package roots, or `--no-register` to opt out of writing.",
1628                        ),
1629                    ));
1630                }
1631                return Ok(());
1632            }
1633            commands::VersionSubCommand::Bump(bump_cmd) => {
1634                if bump_cmd.push {
1635                    crate::cli::auto_commit::set_explicit_push(true);
1636                }
1637                if let Err(e) = run_version_bump_command(&bump_cmd).await {
1638                    return Err(ErrorFactory::operation(
1639                        "version",
1640                        "bump mutated package versions",
1641                        e,
1642                        Some(
1643                            "Preview with `xbp v bump --plan` or `--dry-run`. On a clean tree, packages with commits since the last release are candidates. Use `--auto` or `--all --patch` in non-interactive shells.",
1644                        ),
1645                    ));
1646                }
1647                return Ok(());
1648            }
1649            commands::VersionSubCommand::Domain(domain_cmd) => {
1650                let command = match domain_cmd.command {
1651                    commands::VersionDomainSubCommand::Init(init_cmd) => {
1652                        VersionDomainCommand::Init(VersionDomainInitOptions {
1653                            name: init_cmd.name,
1654                            root: init_cmd.root,
1655                            write: init_cmd.write,
1656                        })
1657                    }
1658                    commands::VersionDomainSubCommand::Doctor(doctor_cmd) => {
1659                        VersionDomainCommand::Doctor(VersionDomainDoctorOptions {
1660                            domain: doctor_cmd.domain,
1661                        })
1662                    }
1663                    commands::VersionDomainSubCommand::Sync(sync_cmd) => {
1664                        VersionDomainCommand::Sync(VersionDomainSyncOptions {
1665                            domain: sync_cmd.domain,
1666                            write: sync_cmd.write,
1667                        })
1668                    }
1669                    commands::VersionDomainSubCommand::Diagnose(diagnose_cmd) => {
1670                        VersionDomainCommand::Diagnose(VersionDomainDiagnoseOptions {
1671                            domain: diagnose_cmd.domain,
1672                            log: diagnose_cmd.log,
1673                        })
1674                    }
1675                    commands::VersionDomainSubCommand::Release(release_cmd) => {
1676                        VersionDomainCommand::Release(VersionDomainReleaseOptions {
1677                            domain: release_cmd.domain,
1678                            patch: release_cmd.patch,
1679                            minor: release_cmd.minor,
1680                            major: release_cmd.major,
1681                            version: release_cmd.version,
1682                            deploy: release_cmd.deploy,
1683                            allow_major_jump: release_cmd.allow_major_jump,
1684                            allow_cross_domain_version: release_cmd.allow_cross_domain_version,
1685                        })
1686                    }
1687                };
1688                if let Err(e) =
1689                    run_version_domain_command(VersionDomainCommandOptions { command }).await
1690                {
1691                    return Err(ErrorFactory::operation(
1692                        "version",
1693                        "run version-domain command",
1694                        e,
1695                        Some("Run `xbp version domain -h` to inspect supported usage."),
1696                    ));
1697                }
1698                return Ok(());
1699            }
1700            commands::VersionSubCommand::Workspace(workspace_cmd) => {
1701                let (repo, json, workspace_command) = match workspace_cmd.command {
1702                    commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
1703                        check_cmd.target.repo,
1704                        check_cmd.target.json,
1705                        WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
1706                            version: check_cmd.version,
1707                        }),
1708                    ),
1709                    commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
1710                        sync_cmd.target.repo,
1711                        sync_cmd.target.json,
1712                        WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
1713                            version: sync_cmd.version,
1714                            write: sync_cmd.write,
1715                        }),
1716                    ),
1717                    commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
1718                        validate_cmd.target.repo,
1719                        validate_cmd.target.json,
1720                        WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
1721                            package: validate_cmd.package,
1722                            cargo_check: validate_cmd.cargo_check,
1723                            package_dry_run: validate_cmd.package_dry_run,
1724                        }),
1725                    ),
1726                    commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
1727                        match publish_cmd.command {
1728                            commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
1729                                plan_cmd.target.repo,
1730                                plan_cmd.target.json,
1731                                WorkspaceVersionCommand::PublishPlan(WorkspacePublishPlanOptions {
1732                                    only: plan_cmd.only,
1733                                    include_prereqs: plan_cmd.include_prereqs,
1734                                }),
1735                            ),
1736                            commands::VersionWorkspacePublishSubCommand::Heal(heal_cmd) => (
1737                                heal_cmd.target.repo,
1738                                heal_cmd.target.json,
1739                                WorkspaceVersionCommand::PublishHeal(WorkspacePublishHealOptions {
1740                                    only: heal_cmd.only,
1741                                    include_prereqs: heal_cmd.include_prereqs,
1742                                    write: heal_cmd.write,
1743                                    version: heal_cmd.version,
1744                                }),
1745                            ),
1746                            commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
1747                                run_cmd.target.repo,
1748                                run_cmd.target.json,
1749                                WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
1750                                    dry_run: run_cmd.dry_run,
1751                                    from: run_cmd.from,
1752                                    only: run_cmd.only,
1753                                    include_prereqs: run_cmd.include_prereqs,
1754                                    continue_on_error: run_cmd.continue_on_error,
1755                                    allow_dirty: run_cmd.allow_dirty,
1756                                    auto_fix: run_cmd.auto_fix,
1757                                    timeout_seconds: run_cmd.timeout_seconds,
1758                                    poll_interval_seconds: run_cmd.poll_interval_seconds,
1759                                }),
1760                            ),
1761                        }
1762                    }
1763                };
1764
1765                if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
1766                    repo,
1767                    json,
1768                    command: workspace_command,
1769                })
1770                .await
1771                {
1772                    return Err(ErrorFactory::operation(
1773                        "version",
1774                        "run workspace version command",
1775                        e,
1776                        Some("Run `xbp version workspace -h` to inspect supported usage."),
1777                    ));
1778                }
1779                return Ok(());
1780            }
1781        }
1782    }
1783
1784    if let Err(e) = run_version_command(resolved_target, git, debug).await {
1785        return Err(ErrorFactory::operation(
1786            "version",
1787            "run version command",
1788            e,
1789            Some("Run `xbp version -h` to inspect supported usage."),
1790        ));
1791    }
1792    Ok(())
1793}
1794
1795fn version_release_error_hint(error: &str) -> Option<&'static str> {
1796    if error.contains("Working tree is dirty") || error.contains("uncommitted changes") {
1797        return Some(
1798            "Use `--allow-dirty` if only generated files changed. `--force` also allows a dirty tree when `--publish` is enabled.",
1799        );
1800    }
1801    if error.contains("CARGO_REGISTRY_TOKEN") || error.contains("no token found") {
1802        return Some(
1803            "Configure a crates.io token with `xbp config crates set-key` or export `CARGO_REGISTRY_TOKEN`.",
1804        );
1805    }
1806    if error.contains("Workspace version drift detected") {
1807        return Some(
1808            "Run `xbp version workspace sync --write` to align crate versions, or pass `--force` to auto-sync during release.",
1809        );
1810    }
1811    if error.contains("Workspace closure detected")
1812        || error.contains("workspace publish command failed")
1813    {
1814        return Some(
1815            "For multi-crate workspaces, inspect the publish output and run `xbp version workspace publish plan --include-prereqs` first.",
1816        );
1817    }
1818    if error.contains("Duplicate port found") || error.contains("Duplicate service name") {
1819        return Some(
1820            "Duplicate service ports/names are deploy-time checks. Re-run `xbp version release` (release no longer blocks on them). Fix ports in project config (`.xbp/xbp.toml` by default) before `xbp deploy`/`xbp start`.",
1821        );
1822    }
1823    if error.contains("Release cancelled") {
1824        return Some(
1825            "Re-run with `--allow-dirty` to skip dirty-tree prompts, or `--force` to auto-heal workspace versions and allow dirty publishes.",
1826        );
1827    }
1828    None
1829}
1830
1831fn should_print_help(cli: &Cli) -> bool {
1832    cli.command.is_none()
1833        && !cli.list
1834        && !cli.logs
1835        && !cli.commands
1836        && cli.query.is_none()
1837        && cli.port.is_none()
1838}
1839
1840/// When both root help (`-h`/`--help`) and a query (`-q`/`--query`) appear,
1841/// drop the help flags so clap does not short-circuit before parsing the query.
1842/// Subcommand help (`xbp workers -h`) is unaffected when no root `-q` is present.
1843fn preprocess_argv_for_help_query(args: Vec<String>) -> Vec<String> {
1844    let has_query = args.iter().skip(1).any(|a| {
1845        a == "-q" || a == "--query" || a.starts_with("--query=") || a.starts_with("-q=")
1846    });
1847    if !has_query {
1848        return args;
1849    }
1850    args.into_iter()
1851        .filter(|a| a != "-h" && a != "--help")
1852        .collect()
1853}
1854
1855#[cfg(test)]
1856mod help_query_argv_tests {
1857    use super::preprocess_argv_for_help_query;
1858
1859    #[test]
1860    fn strips_root_help_when_query_present() {
1861        let out = preprocess_argv_for_help_query(vec![
1862            "xbp".into(),
1863            "-h".into(),
1864            "-q".into(),
1865            "deploy".into(),
1866        ]);
1867        assert_eq!(out, vec!["xbp", "-q", "deploy"]);
1868    }
1869
1870    #[test]
1871    fn leaves_argv_when_no_query() {
1872        let out = preprocess_argv_for_help_query(vec!["xbp".into(), "diag".into(), "-h".into()]);
1873        assert_eq!(out, vec!["xbp", "diag", "-h"]);
1874    }
1875
1876    #[test]
1877    fn strips_long_help_with_long_query() {
1878        let out = preprocess_argv_for_help_query(vec![
1879            "xbp".into(),
1880            "--help".into(),
1881            "--query".into(),
1882            "webhook".into(),
1883        ]);
1884        assert_eq!(out, vec!["xbp", "--query", "webhook"]);
1885    }
1886
1887    #[test]
1888    fn strips_both_help_forms() {
1889        let out = preprocess_argv_for_help_query(vec![
1890            "xbp".into(),
1891            "-h".into(),
1892            "--help".into(),
1893            "-q".into(),
1894            "ports".into(),
1895        ]);
1896        assert_eq!(out, vec!["xbp", "-q", "ports"]);
1897    }
1898
1899    #[test]
1900    fn keeps_query_equals_form() {
1901        let out = preprocess_argv_for_help_query(vec![
1902            "xbp".into(),
1903            "-h".into(),
1904            "--query=deploy".into(),
1905        ]);
1906        assert_eq!(out, vec!["xbp", "--query=deploy"]);
1907    }
1908
1909    #[test]
1910    fn keeps_short_query_equals_form() {
1911        let out =
1912            preprocess_argv_for_help_query(vec!["xbp".into(), "--help".into(), "-q=nginx".into()]);
1913        assert_eq!(out, vec!["xbp", "-q=nginx"]);
1914    }
1915
1916    #[test]
1917    fn leaves_plain_help_alone() {
1918        let out = preprocess_argv_for_help_query(vec!["xbp".into(), "--help".into()]);
1919        assert_eq!(out, vec!["xbp", "--help"]);
1920    }
1921
1922    #[test]
1923    fn leaves_subcommand_help_without_root_query() {
1924        let out = preprocess_argv_for_help_query(vec![
1925            "xbp".into(),
1926            "workers".into(),
1927            "list".into(),
1928            "-h".into(),
1929        ]);
1930        assert_eq!(out, vec!["xbp", "workers", "list", "-h"]);
1931    }
1932
1933    #[test]
1934    fn strips_help_even_if_query_comes_first() {
1935        let out = preprocess_argv_for_help_query(vec![
1936            "xbp".into(),
1937            "-q".into(),
1938            "linear".into(),
1939            "-h".into(),
1940        ]);
1941        assert_eq!(out, vec!["xbp", "-q", "linear"]);
1942    }
1943
1944    #[test]
1945    fn empty_args_passthrough() {
1946        let out = preprocess_argv_for_help_query(vec!["xbp".into()]);
1947        assert_eq!(out, vec!["xbp"]);
1948    }
1949}
1950
1951fn background_cursor_ingest_trigger(command: Option<&commands::Commands>) -> Option<&'static str> {
1952    match command {
1953        None => None,
1954        Some(commands::Commands::Cursor(_)) => None,
1955        Some(commands::Commands::Diag(_)) => None,
1956        Some(commands::Commands::Whoami) => None,
1957        Some(commands::Commands::Update(_)) => None,
1958        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1959            None => Some("login"),
1960            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1961        },
1962        Some(other) => Some(commands::command_label(other)),
1963    }
1964}
1965
1966fn background_system_inventory_refresh_trigger(
1967    command: Option<&commands::Commands>,
1968) -> Option<&'static str> {
1969    match command {
1970        None => None,
1971        Some(commands::Commands::Cursor(_)) => None,
1972        Some(commands::Commands::Diag(_)) => None,
1973        Some(commands::Commands::Whoami) => None,
1974        Some(commands::Commands::Update(_)) => None,
1975        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1976            None => Some("login"),
1977            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1978        },
1979        Some(other) => Some(commands::command_label(other)),
1980    }
1981}
1982
1983fn print_service_usage() {
1984    ui::configure_color_output();
1985    println!();
1986    println!("{}", "xbp service".bright_magenta().bold());
1987    ui::divider(28);
1988    println!(
1989        "{} {}",
1990        "Usage:".bright_cyan().bold(),
1991        "xbp service [command] [service-name]".bright_white()
1992    );
1993    println!();
1994    println!("Running with no arguments opens an interactive preview + picker.");
1995    ui::section("Commands");
1996    for command in ["build", "install", "start", "dev", "pre"] {
1997        println!("  {}", command.bright_cyan());
1998    }
1999    ui::section("Examples");
2000    println!(
2001        "  {}",
2002        "xbp service                      # interactive: pick service → command".dimmed()
2003    );
2004    println!(
2005        "  {}",
2006        "xbp service start                # pick from services that support start".dimmed()
2007    );
2008    println!("  {}", "xbp service build zeus".dimmed());
2009    ui::tip("Run `xbp service --help <service-name>` for service-specific guidance. All runs are recorded under $HOME/.xbp/logs/service/");
2010}
2011
2012#[cfg(test)]
2013mod tests {
2014    use super::*;
2015    use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
2016
2017    #[test]
2018    fn plain_cli_invocation_prints_help() {
2019        let cli = Cli::try_parse_from(["xbp"]).expect("parse");
2020        assert!(should_print_help(&cli));
2021    }
2022
2023    #[test]
2024    fn root_version_short_v_displays_version() {
2025        let err = Cli::try_parse_from(["xbp", "-v"]).expect_err("version");
2026        assert_eq!(err.kind(), ClapErrorKind::DisplayVersion);
2027    }
2028
2029    #[test]
2030    fn root_version_capital_v_and_long_still_display_version() {
2031        let err_v = Cli::try_parse_from(["xbp", "-V"]).expect_err("version -V");
2032        assert_eq!(err_v.kind(), ClapErrorKind::DisplayVersion);
2033        let err_long = Cli::try_parse_from(["xbp", "--version"]).expect_err("version long");
2034        assert_eq!(err_long.kind(), ClapErrorKind::DisplayVersion);
2035    }
2036
2037    #[test]
2038    fn list_flag_skips_help_short_circuit() {
2039        let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
2040        assert!(!should_print_help(&cli));
2041    }
2042
2043    #[test]
2044    fn commands_flag_skips_help_short_circuit() {
2045        let cli = Cli::try_parse_from(["xbp", "--commands"]).expect("parse");
2046        assert!(!should_print_help(&cli));
2047        assert!(cli.commands);
2048    }
2049
2050    #[test]
2051    fn install_without_package_parses_and_is_optional() {
2052        let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
2053        let Some(Commands::Install {
2054            list,
2055            force,
2056            package,
2057        }) = cli.command
2058        else {
2059            panic!("expected install command");
2060        };
2061        assert!(!list);
2062        assert!(!force);
2063        assert!(package.is_none());
2064    }
2065
2066    #[test]
2067    fn install_with_package_still_parses() {
2068        let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
2069        let Some(Commands::Install {
2070            list,
2071            force,
2072            package,
2073        }) = cli.command
2074        else {
2075            panic!("expected install command");
2076        };
2077        assert!(!list);
2078        assert!(!force);
2079        assert_eq!(package.as_deref(), Some("docker"));
2080    }
2081
2082    #[test]
2083    fn install_force_flag_parses() {
2084        let cli = Cli::try_parse_from(["xbp", "install", "--force", "nx-brew"]).expect("parse");
2085        let Some(Commands::Install {
2086            list,
2087            force,
2088            package,
2089        }) = cli.command
2090        else {
2091            panic!("expected install command");
2092        };
2093        assert!(!list);
2094        assert!(force);
2095        assert_eq!(package.as_deref(), Some("nx-brew"));
2096    }
2097
2098    #[test]
2099    fn install_list_flag_parses() {
2100        let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
2101        let Some(Commands::Install {
2102            list,
2103            force: _,
2104            package,
2105        }) = cli.command
2106        else {
2107            panic!("expected install command");
2108        };
2109        assert!(list);
2110        assert!(package.is_none());
2111    }
2112
2113    #[test]
2114    fn install_short_list_flag_parses() {
2115        let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
2116        let Some(Commands::Install {
2117            list,
2118            force: _,
2119            package,
2120        }) = cli.command
2121        else {
2122            panic!("expected install command");
2123        };
2124        assert!(list);
2125        assert!(package.is_none());
2126    }
2127
2128    #[test]
2129    fn install_ls_alias_parses_as_package() {
2130        let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
2131        let Some(Commands::Install {
2132            list,
2133            force: _,
2134            package,
2135        }) = cli.command
2136        else {
2137            panic!("expected install command");
2138        };
2139        assert!(!list);
2140        assert_eq!(package.as_deref(), Some("ls"));
2141    }
2142
2143    #[test]
2144    fn version_release_make_latest_parses_explicit_value() {
2145        let cli = Cli::try_parse_from([
2146            "xbp",
2147            "version",
2148            "release",
2149            "--version",
2150            "1.2.3",
2151            "--make-latest",
2152            "false",
2153        ])
2154        .expect("parse");
2155        let Some(Commands::Version(version_cmd)) = cli.command else {
2156            panic!("expected version command");
2157        };
2158        assert!(version_cmd.explicit_version.is_none());
2159        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2160            panic!("expected version release subcommand");
2161        };
2162        assert!(matches!(
2163            release_cmd.make_latest,
2164            VersionReleaseLatest::False
2165        ));
2166    }
2167
2168    #[test]
2169    fn version_release_make_latest_defaults_to_legacy() {
2170        let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
2171            .expect("parse");
2172        let Some(Commands::Version(version_cmd)) = cli.command else {
2173            panic!("expected version command");
2174        };
2175        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2176            panic!("expected version release subcommand");
2177        };
2178        assert!(matches!(
2179            release_cmd.make_latest,
2180            VersionReleaseLatest::Legacy
2181        ));
2182        assert!(!release_cmd.dry_run);
2183        assert!(!release_cmd.publish);
2184    }
2185
2186    #[test]
2187    fn version_release_parses_dry_run_and_publish() {
2188        let cli = Cli::try_parse_from([
2189            "xbp",
2190            "version",
2191            "release",
2192            "--version",
2193            "1.2.3",
2194            "--publish",
2195            "--dry-run",
2196        ])
2197        .expect("parse");
2198        let Some(Commands::Version(version_cmd)) = cli.command else {
2199            panic!("expected version command");
2200        };
2201        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
2202            panic!("expected version release subcommand");
2203        };
2204        assert!(release_cmd.dry_run);
2205        assert!(release_cmd.publish);
2206    }
2207
2208    #[test]
2209    fn version_explicit_flag_parses() {
2210        let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
2211        let Some(Commands::Version(version_cmd)) = cli.command else {
2212            panic!("expected version command");
2213        };
2214        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
2215        assert!(version_cmd.target.is_none());
2216        assert!(version_cmd.command.is_none());
2217    }
2218
2219    #[test]
2220    fn version_short_explicit_flag_parses_with_alias() {
2221        let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
2222        let Some(Commands::Version(version_cmd)) = cli.command else {
2223            panic!("expected version command");
2224        };
2225        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
2226        assert!(version_cmd.target.is_none());
2227        assert!(version_cmd.command.is_none());
2228    }
2229}