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 router;
9pub mod ui;
10
11pub use handlers::*;
12
13use crate::cli::app::AppContext;
14use crate::cli::error::{CliError, CliResult, ErrorFactory};
15use crate::commands::cli_session::{
16    require_authenticated_cli_session, run_login_status, run_logout, run_whoami,
17};
18use crate::commands::curl;
19#[cfg(feature = "openapi-gen")]
20use crate::commands::generate_openapi::{run_generate_openapi, GenerateOpenApiArgs};
21use crate::commands::generate_systemd::{run_generate_systemd, GenerateSystemdArgs};
22use crate::commands::redeploy_v2::run_redeploy_v2;
23use crate::commands::{
24    install_package, list_services, open_global_config, pick_service_for_command, run_commit,
25    run_config, run_config_cloudflare_account_delete, run_config_cloudflare_account_set,
26    run_config_cloudflare_account_show, run_config_cloudflare_login, run_config_cloudflare_setup,
27    run_config_cloudflare_status, run_config_crates_login, run_config_crates_logout,
28    run_config_linear_select_initiative, run_config_openapi_setup, run_config_openapi_show,
29    run_config_publish_setup, run_config_release_setup, run_config_secret_delete,
30    run_config_secret_set, run_config_secret_show, run_cursor_ingest,
31    run_generate_config, run_init, run_login, run_publish_command, run_redeploy,
32    run_redeploy_service, run_service_command, run_service_interactive, run_setup,
33    run_version_bump_command, run_version_command, run_version_discover_services,
34    run_version_domain_command, run_version_release_command, run_version_workspace_command,
35    show_service_help, CommitArgs, CommitError, CommitRunOutcome, GenerateConfigArgs,
36    PublishCommandOptions, ReleaseLatestPolicy, VersionDomainCommand, VersionDomainCommandOptions,
37    VersionDomainDiagnoseOptions, VersionDomainDoctorOptions, VersionDomainInitOptions,
38    VersionDomainReleaseOptions, VersionDomainSyncOptions, VersionReleaseOptions,
39    WorkspacePublishPlanOptions, WorkspacePublishRunOptions, WorkspaceVersionCheckOptions,
40    WorkspaceVersionCommand, WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions,
41    WorkspaceVersionValidateOptions,
42};
43use crate::commands::{run_diag, run_nginx};
44use crate::config::sync_versioning_files_registry;
45use crate::logging::{init_logger, log_error, log_info, log_success, log_warn};
46use clap::{error::ErrorKind as ClapErrorKind, Parser};
47use colored::Colorize;
48use commands::Cli;
49
50pub async fn run() -> CliResult<()> {
51    let cli: Cli = match Cli::try_parse() {
52        Ok(cli) => cli,
53        Err(err) => {
54            let kind = err.kind();
55            let rendered = err.to_string();
56            if kind == ClapErrorKind::DisplayVersion {
57                help_render::emit_version_line(env!("CARGO_PKG_VERSION"));
58                return Ok(());
59            }
60            if kind == ClapErrorKind::DisplayHelp
61                || kind == ClapErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
62                || rendered.contains("Manage the XBP API server")
63            {
64                ui::configure_color_output();
65                if help_render::is_root_help_text(&rendered) {
66                    crate::commands::print_help().await;
67                } else {
68                    help_render::emit_styled_help(&rendered, help_render::HelpScope::Auto);
69                }
70                return Ok(());
71            }
72            return Err(ErrorFactory::clap_parse(err));
73        }
74    };
75
76    if cli.commands {
77        help_render::print_command_catalog();
78        return Ok(());
79    }
80
81    // Keep plain `xbp` aligned with `xbp --help` instead of entering
82    // interactive project selection flow.
83    if should_print_help(&cli) {
84        ui::configure_color_output();
85        crate::commands::print_help().await;
86        return Ok(());
87    }
88
89    let debug: bool = cli.debug;
90    ui::configure_color_output();
91    let command_name = cli
92        .command
93        .as_ref()
94        .map(commands::command_label)
95        .unwrap_or("interactive");
96    ui::print_cli_header(command_name, debug);
97
98    if let Err(e) = init_logger(debug).await {
99        let _ = log_error(
100            "system",
101            "Failed to initialize logger",
102            Some(&e.to_string()),
103        )
104        .await;
105    }
106    if let Err(e) = sync_versioning_files_registry() {
107        let _ = log_warn("config", "Failed to sync versioning registry", Some(&e)).await;
108    }
109
110    let background_cursor_trigger =
111        background_cursor_ingest_trigger(cli.command.as_ref()).map(str::to_string);
112    let background_system_inventory_trigger =
113        background_system_inventory_refresh_trigger(cli.command.as_ref()).map(str::to_string);
114    let mut ctx = AppContext::new(debug);
115    let result = router::dispatch(cli, &mut ctx).await;
116
117    if result.is_ok() {
118        if let Some(trigger) = background_cursor_trigger {
119            if let Err(error) =
120                crate::commands::cursor_ingest::maybe_start_background_cursor_ingest(&trigger)
121            {
122                let _ = log_warn(
123                    "cursor",
124                    "Background Cursor ingest could not be started",
125                    Some(&error),
126                )
127                .await;
128            }
129        }
130        if let Some(trigger) = background_system_inventory_trigger {
131            if let Err(error) = crate::commands::system_inventory_refresh::
132                maybe_start_background_system_inventory_refresh(&trigger)
133            {
134                let _ = log_warn(
135                    "codetime",
136                    "Background system inventory refresh could not be started",
137                    Some(&error),
138                )
139                .await;
140            }
141        }
142    }
143
144    result
145}
146
147pub(super) async fn handle_init(debug: bool) -> CliResult<()> {
148    if let Err(e) = run_init(debug).await {
149        let _ = log_error("init", "Init failed", Some(&e)).await;
150        return Err(e.into());
151    }
152    Ok(())
153}
154
155pub(super) async fn handle_commit(cmd: commands::CommitCmd) -> CliResult<()> {
156    let args = CommitArgs {
157        dry_run: cmd.dry_run,
158        push: cmd.push,
159        no_ai: cmd.no_ai,
160        model: cmd.model,
161        scope: cmd.scope,
162    };
163
164    match run_commit(args).await {
165        Ok(CommitRunOutcome::Completed) | Ok(CommitRunOutcome::NothingToCommit) => Ok(()),
166        Err(error) => Err(map_commit_error(error)),
167    }
168}
169
170fn map_commit_error(error: CommitError) -> CliError {
171    match error {
172        CommitError::PushFailed {
173            summary,
174            commit_sha,
175        } => {
176            let details = if let Some(commit_sha) = commit_sha {
177                format!("Created local commit {commit_sha} but push failed: {summary}")
178            } else {
179                format!("Push failed: {summary}")
180            };
181            ErrorFactory::operation(
182                "commit",
183                "push changes",
184                details,
185                commit_push_hint(&summary),
186            )
187        }
188        CommitError::TerminalSessionsBlocked(message) => ErrorFactory::validation(
189            "commit",
190            message,
191            Some("Add `/terminals` to `.gitignore` and run `git rm -r --cached terminals/`."),
192        ),
193        CommitError::Operation(message) => ErrorFactory::operation(
194            "commit",
195            "create conventional commit",
196            message,
197            Some("Run `xbp commit --dry-run` after saving changes."),
198        ),
199    }
200}
201
202fn commit_push_hint(summary: &str) -> Option<&'static str> {
203    let lowered = summary.to_ascii_lowercase();
204    if lowered.contains("auto-rebase failed") || lowered.contains("conflict") {
205        Some("Resolve rebase conflicts, run `git rebase --continue`, then `git push`.")
206    } else if lowered.contains("behind") || lowered.contains("non-fast-forward") {
207        Some("XBP should auto-rebase; if it did not, run `git pull --rebase` then `git push`.")
208    } else {
209        Some("Verify GitHub auth and branch permissions, then push again.")
210    }
211}
212
213pub(super) async fn handle_publish(cmd: commands::PublishCmd, _debug: bool) -> CliResult<()> {
214    let options = PublishCommandOptions {
215        dry_run: cmd.dry_run,
216        allow_dirty: cmd.allow_dirty,
217        force: cmd.force,
218        include_prereqs: cmd.include_prereqs,
219        target: cmd.target,
220        service: cmd.service,
221        manifest_path: cmd.manifest_path,
222        expected_version: None,
223    };
224
225    if let Err(e) = run_publish_command(options).await {
226        let _ = log_error("publish", "Publish command failed", Some(&e)).await;
227        return Err(ErrorFactory::operation(
228            "publish",
229            "run publish workflow",
230            e,
231            Some("Configure project targets with `xbp config npm setup-release` or `xbp config crates setup-release`."),
232        ));
233    }
234
235    Ok(())
236}
237
238pub(super) async fn handle_setup(debug: bool) -> CliResult<()> {
239    if let Err(e) = ui::with_loader("Running setup checks", run_setup(debug)).await {
240        let _ = log_error("setup", "Setup failed", Some(&e)).await;
241        return Err(ErrorFactory::operation(
242            "setup",
243            "setup environment",
244            e,
245            Some("Run with `--debug` for command-level output."),
246        ));
247    }
248    Ok(())
249}
250
251pub(super) async fn handle_redeploy(service_name: Option<String>, debug: bool) -> CliResult<()> {
252    if let Some(name) = service_name {
253        if let Err(e) = ui::with_loader(
254            &format!("Redeploying service `{}`", name),
255            run_redeploy_service(&name, debug),
256        )
257        .await
258        {
259            let _ = log_error("redeploy", "Service redeploy failed", Some(&e)).await;
260            return Err(ErrorFactory::operation(
261                "redeploy",
262                &format!("redeploy service `{}`", name),
263                e,
264                Some("Verify service name with `xbp services`."),
265            ));
266        }
267    } else if let Err(e) = ui::with_loader("Redeploying full project", run_redeploy()).await {
268        let _ = log_error("redeploy", "Redeploy failed", Some(&e)).await;
269        return Err(ErrorFactory::operation(
270            "redeploy",
271            "redeploy project",
272            e,
273            Some("Try `xbp redeploy <service>` for scoped retries."),
274        ));
275    }
276    Ok(())
277}
278
279pub(super) async fn handle_redeploy_v2(cmd: commands::RedeployV2Cmd, debug: bool) -> CliResult<()> {
280    let _ = log_info("redeploy_v2", "Starting remote redeploy process", None).await;
281    match run_redeploy_v2(cmd.password, cmd.username, cmd.host, cmd.project_dir, debug).await {
282        Ok(()) => Ok(()),
283        Err(e) => {
284            let _ = log_error("redeploy_v2", "Remote redeploy failed", Some(&e)).await;
285            Err(e.into())
286        }
287    }
288}
289
290pub(super) async fn handle_config(cmd: commands::ConfigCmd, debug: bool) -> CliResult<()> {
291    let commands::ConfigCmd {
292        project,
293        no_open,
294        provider,
295    } = cmd;
296
297    if provider.is_some() && (project || no_open) {
298        return Err(ErrorFactory::validation(
299            "config",
300            "`xbp config <provider> ...` cannot be combined with `--project` or `--no-open`.",
301            Some("Run either provider key management OR project/global config actions."),
302        ));
303    }
304
305    if let Some(provider_cmd) = provider {
306        match provider_cmd {
307            commands::ConfigProviderCmd::Openrouter(subcmd) => match subcmd.action {
308                commands::ConfigSecretAction::SetKey { key } => {
309                    if let Err(e) = run_config_secret_set("openrouter", key).await {
310                        let _ = log_error("config", "Failed to set OpenRouter key", Some(&e)).await;
311                        return Err(ErrorFactory::operation(
312                            "config",
313                            "set OpenRouter key",
314                            e,
315                            Some("Run `xbp config openrouter show` to confirm key state."),
316                        ));
317                    }
318                }
319                commands::ConfigSecretAction::DeleteKey => {
320                    if let Err(e) = run_config_secret_delete("openrouter").await {
321                        let _ =
322                            log_error("config", "Failed to delete OpenRouter key", Some(&e)).await;
323                        return Err(ErrorFactory::operation(
324                            "config",
325                            "delete OpenRouter key",
326                            e,
327                            Some("Use `xbp config openrouter show` to verify removal."),
328                        ));
329                    }
330                }
331                commands::ConfigSecretAction::Show { raw } => {
332                    if let Err(e) = run_config_secret_show("openrouter", raw).await {
333                        let _ =
334                            log_error("config", "Failed to show OpenRouter key", Some(&e)).await;
335                        return Err(ErrorFactory::operation(
336                            "config",
337                            "show OpenRouter key",
338                            e,
339                            None,
340                        ));
341                    }
342                }
343            },
344            commands::ConfigProviderCmd::Github(subcmd) => match subcmd.action {
345                commands::ConfigSecretAction::SetKey { key } => {
346                    if let Err(e) = run_config_secret_set("github", key).await {
347                        let _ = log_error("config", "Failed to set GitHub token", Some(&e)).await;
348                        return Err(ErrorFactory::operation(
349                            "config",
350                            "set GitHub token",
351                            e,
352                            Some("Use a token with repo scope for private repos."),
353                        ));
354                    }
355                }
356                commands::ConfigSecretAction::DeleteKey => {
357                    if let Err(e) = run_config_secret_delete("github").await {
358                        let _ =
359                            log_error("config", "Failed to delete GitHub token", Some(&e)).await;
360                        return Err(ErrorFactory::operation(
361                            "config",
362                            "delete GitHub token",
363                            e,
364                            None,
365                        ));
366                    }
367                }
368                commands::ConfigSecretAction::Show { raw } => {
369                    if let Err(e) = run_config_secret_show("github", raw).await {
370                        let _ = log_error("config", "Failed to show GitHub token", Some(&e)).await;
371                        return Err(ErrorFactory::operation(
372                            "config",
373                            "show GitHub token",
374                            e,
375                            None,
376                        ));
377                    }
378                }
379            },
380            commands::ConfigProviderCmd::Cloudflare(subcmd) => match subcmd.action {
381                None => {
382                    if let Err(e) = run_config_cloudflare_setup().await {
383                        let _ =
384                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
385                        return Err(ErrorFactory::operation(
386                            "config",
387                            "configure Cloudflare",
388                            e,
389                            Some(
390                                "Run `xbp config cloudflare status` to inspect credential sources.",
391                            ),
392                        ));
393                    }
394                }
395                Some(commands::CloudflareConfigAction::SetKey { key }) => {
396                    if let Err(e) = run_config_secret_set("cloudflare", key).await {
397                        let _ =
398                            log_error("config", "Failed to set Cloudflare token", Some(&e)).await;
399                        return Err(ErrorFactory::operation(
400                            "config",
401                            "set Cloudflare token",
402                            e,
403                            Some("Use a Cloudflare API token with Secrets Store and DNS permissions."),
404                        ));
405                    }
406                }
407                Some(commands::CloudflareConfigAction::DeleteKey) => {
408                    if let Err(e) = run_config_secret_delete("cloudflare").await {
409                        let _ = log_error("config", "Failed to delete Cloudflare token", Some(&e))
410                            .await;
411                        return Err(ErrorFactory::operation(
412                            "config",
413                            "delete Cloudflare token",
414                            e,
415                            None,
416                        ));
417                    }
418                }
419                Some(commands::CloudflareConfigAction::ShowKey { raw }) => {
420                    if let Err(e) = run_config_secret_show("cloudflare", raw).await {
421                        let _ =
422                            log_error("config", "Failed to show Cloudflare token", Some(&e)).await;
423                        return Err(ErrorFactory::operation(
424                            "config",
425                            "show Cloudflare token",
426                            e,
427                            None,
428                        ));
429                    }
430                }
431                Some(commands::CloudflareConfigAction::SetAccountId { account_id }) => {
432                    if let Err(e) = run_config_cloudflare_account_set(account_id).await {
433                        let _ =
434                            log_error("config", "Failed to set Cloudflare account ID", Some(&e))
435                                .await;
436                        return Err(ErrorFactory::operation(
437                            "config",
438                            "set Cloudflare account ID",
439                            e,
440                            None,
441                        ));
442                    }
443                }
444                Some(commands::CloudflareConfigAction::DeleteAccountId) => {
445                    if let Err(e) = run_config_cloudflare_account_delete().await {
446                        let _ =
447                            log_error("config", "Failed to delete Cloudflare account ID", Some(&e))
448                                .await;
449                        return Err(ErrorFactory::operation(
450                            "config",
451                            "delete Cloudflare account ID",
452                            e,
453                            None,
454                        ));
455                    }
456                }
457                Some(commands::CloudflareConfigAction::ShowAccountId { raw }) => {
458                    if let Err(e) = run_config_cloudflare_account_show(raw).await {
459                        let _ =
460                            log_error("config", "Failed to show Cloudflare account ID", Some(&e))
461                                .await;
462                        return Err(ErrorFactory::operation(
463                            "config",
464                            "show Cloudflare account ID",
465                            e,
466                            None,
467                        ));
468                    }
469                }
470                Some(commands::CloudflareConfigAction::Login) => {
471                    if let Err(e) = run_config_cloudflare_login().await {
472                        let _ = log_error("config", "Failed to link Cloudflare", Some(&e)).await;
473                        return Err(ErrorFactory::operation(
474                            "config",
475                            "link Cloudflare",
476                            e,
477                            None,
478                        ));
479                    }
480                }
481                Some(commands::CloudflareConfigAction::Status) => {
482                    if let Err(e) = run_config_cloudflare_status().await {
483                        let _ =
484                            log_error("config", "Failed to show Cloudflare status", Some(&e)).await;
485                        return Err(ErrorFactory::operation(
486                            "config",
487                            "show Cloudflare status",
488                            e,
489                            None,
490                        ));
491                    }
492                }
493                Some(commands::CloudflareConfigAction::Setup) => {
494                    if let Err(e) = run_config_cloudflare_setup().await {
495                        let _ =
496                            log_error("config", "Failed to configure Cloudflare", Some(&e)).await;
497                        return Err(ErrorFactory::operation(
498                            "config",
499                            "configure Cloudflare",
500                            e,
501                            None,
502                        ));
503                    }
504                }
505            },
506            commands::ConfigProviderCmd::Linear(subcmd) => match subcmd.action {
507                commands::LinearConfigAction::SetKey { key } => {
508                    if let Err(e) = run_config_secret_set("linear", key).await {
509                        let _ = log_error("config", "Failed to set Linear API key", Some(&e)).await;
510                        return Err(ErrorFactory::operation(
511                            "config",
512                            "set Linear API key",
513                            e,
514                            Some("Use this to link Linear issue IDs in generated release notes and publish release updates to Linear initiatives."),
515                        ));
516                    }
517                }
518                commands::LinearConfigAction::DeleteKey => {
519                    if let Err(e) = run_config_secret_delete("linear").await {
520                        let _ =
521                            log_error("config", "Failed to delete Linear API key", Some(&e)).await;
522                        return Err(ErrorFactory::operation(
523                            "config",
524                            "delete Linear API key",
525                            e,
526                            None,
527                        ));
528                    }
529                }
530                commands::LinearConfigAction::Show { raw } => {
531                    if let Err(e) = run_config_secret_show("linear", raw).await {
532                        let _ =
533                            log_error("config", "Failed to show Linear API key", Some(&e)).await;
534                        return Err(ErrorFactory::operation(
535                            "config",
536                            "show Linear API key",
537                            e,
538                            None,
539                        ));
540                    }
541                }
542                commands::LinearConfigAction::SelectInitiative => {
543                    if let Err(e) = run_config_linear_select_initiative().await {
544                        let _ = log_error(
545                            "config",
546                            "Failed to select repo Linear initiative",
547                            Some(&e),
548                        )
549                        .await;
550                        return Err(ErrorFactory::operation(
551                            "config",
552                            "select repo Linear initiative",
553                            e,
554                            Some("Run this inside an XBP project and configure a Linear key with `xbp config linear set-key` first."),
555                        ));
556                    }
557                }
558            },
559            commands::ConfigProviderCmd::Npm(subcmd) => match subcmd.action {
560                commands::RegistryConfigAction::SetKey { key } => {
561                    if let Err(e) = run_config_secret_set("npm", key).await {
562                        let _ = log_error("config", "Failed to set npm token", Some(&e)).await;
563                        return Err(ErrorFactory::operation(
564                            "config",
565                            "set npm token",
566                            e,
567                            Some("Use a valid npm automation or granular publish token."),
568                        ));
569                    }
570                }
571                commands::RegistryConfigAction::DeleteKey => {
572                    if let Err(e) = run_config_secret_delete("npm").await {
573                        let _ = log_error("config", "Failed to delete npm token", Some(&e)).await;
574                        return Err(ErrorFactory::operation(
575                            "config",
576                            "delete npm token",
577                            e,
578                            None,
579                        ));
580                    }
581                }
582                commands::RegistryConfigAction::Show { raw } => {
583                    if let Err(e) = run_config_secret_show("npm", raw).await {
584                        let _ = log_error("config", "Failed to show npm token", Some(&e)).await;
585                        return Err(ErrorFactory::operation("config", "show npm token", e, None));
586                    }
587                }
588                commands::RegistryConfigAction::SetupRelease => {
589                    if let Err(e) = run_config_publish_setup("npm").await {
590                        let _ =
591                            log_error("config", "Failed to configure npm publish", Some(&e)).await;
592                        return Err(ErrorFactory::operation(
593                            "config",
594                            "configure npm publish",
595                            e,
596                            Some("Run this inside the target XBP project and ensure the package manifest exists."),
597                        ));
598                    }
599                }
600            },
601            commands::ConfigProviderCmd::Crates(subcmd) => match subcmd.action {
602                commands::CratesConfigAction::SetKey { key } => {
603                    if let Err(e) = run_config_secret_set("crates", key).await {
604                        let _ = log_error("config", "Failed to set crates token", Some(&e)).await;
605                        return Err(ErrorFactory::operation(
606                            "config",
607                            "set crates token",
608                            e,
609                            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."),
610                        ));
611                    }
612                }
613                commands::CratesConfigAction::DeleteKey => {
614                    if let Err(e) = run_config_secret_delete("crates").await {
615                        let _ =
616                            log_error("config", "Failed to delete crates token", Some(&e)).await;
617                        return Err(ErrorFactory::operation(
618                            "config",
619                            "delete crates token",
620                            e,
621                            None,
622                        ));
623                    }
624                }
625                commands::CratesConfigAction::Show { raw } => {
626                    if let Err(e) = run_config_secret_show("crates", raw).await {
627                        let _ = log_error("config", "Failed to show crates token", Some(&e)).await;
628                        return Err(ErrorFactory::operation(
629                            "config",
630                            "show crates token",
631                            e,
632                            None,
633                        ));
634                    }
635                }
636                commands::CratesConfigAction::SetupRelease => {
637                    if let Err(e) = run_config_publish_setup("crates").await {
638                        let _ = log_error("config", "Failed to configure crates publish", Some(&e))
639                            .await;
640                        return Err(ErrorFactory::operation(
641                            "config",
642                            "configure crates publish",
643                            e,
644                            Some("Run this inside the target XBP project and point the workflow at the crate manifest you want to publish."),
645                        ));
646                    }
647                }
648                commands::CratesConfigAction::Login { key } => {
649                    if let Err(e) = run_config_crates_login(key).await {
650                        let _ = log_error("config", "Failed to log Cargo into crates.io", Some(&e))
651                            .await;
652                        return Err(ErrorFactory::operation(
653                            "config",
654                            "log Cargo into crates.io",
655                            e,
656                            Some("Store a crates token with `xbp config crates set-key` first, or pass it directly to `xbp config crates login <token>`."),
657                        ));
658                    }
659                }
660                commands::CratesConfigAction::Logout => {
661                    if let Err(e) = run_config_crates_logout().await {
662                        let _ =
663                            log_error("config", "Failed to log Cargo out of crates.io", Some(&e))
664                                .await;
665                        return Err(ErrorFactory::operation(
666                            "config",
667                            "log Cargo out of crates.io",
668                            e,
669                            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."),
670                        ));
671                    }
672                }
673            },
674            commands::ConfigProviderCmd::Release(subcmd) => match subcmd.action {
675                commands::ReleaseConfigAction::Setup => {
676                    if let Err(e) = run_config_release_setup().await {
677                        let _ =
678                            log_error("config", "Failed to configure release settings", Some(&e))
679                                .await;
680                        return Err(ErrorFactory::operation(
681                            "config",
682                            "configure release settings",
683                            e,
684                            Some("Run this inside the target XBP project to update .xbp/xbp.yaml."),
685                        ));
686                    }
687                }
688            },
689            commands::ConfigProviderCmd::Openapi(subcmd) => match subcmd.action {
690                None | Some(commands::OpenapiConfigAction::Setup) => {
691                    if let Err(e) = run_config_openapi_setup().await {
692                        let _ =
693                            log_error("config", "Failed to configure OpenAPI generation", Some(&e))
694                                .await;
695                        return Err(ErrorFactory::operation(
696                            "config",
697                            "configure OpenAPI generation",
698                            e,
699                            Some("Run this inside the target XBP project to update .xbp/xbp.yaml."),
700                        ));
701                    }
702                }
703                Some(commands::OpenapiConfigAction::Show) => {
704                    if let Err(e) = run_config_openapi_show().await {
705                        let _ =
706                            log_error("config", "Failed to show OpenAPI config", Some(&e)).await;
707                        return Err(ErrorFactory::operation(
708                            "config",
709                            "show OpenAPI config",
710                            e,
711                            Some("Run this inside the target XBP project."),
712                        ));
713                    }
714                }
715            },
716        }
717    } else if project {
718        if let Err(e) = run_config(debug).await {
719            return Err(ErrorFactory::operation(
720                "config",
721                "read project config",
722                e,
723                Some("Ensure you're inside an XBP project root."),
724            ));
725        }
726    } else if let Err(e) = open_global_config(no_open).await {
727        let _ = log_error("config", "Failed to open global config", Some(&e)).await;
728        return Err(ErrorFactory::operation(
729            "config",
730            "open global config",
731            e,
732            None,
733        ));
734    }
735    Ok(())
736}
737
738pub(super) async fn handle_install(
739    package: Option<String>,
740    list: bool,
741    force: bool,
742    debug: bool,
743) -> CliResult<()> {
744    if list {
745        crate::commands::print_install_targets_help();
746        return Ok(());
747    }
748
749    let Some(package) = package else {
750        crate::commands::print_install_empty_state();
751        return Ok(());
752    };
753
754    if package.trim().is_empty() {
755        crate::commands::print_install_empty_state();
756        return Ok(());
757    }
758
759    if crate::commands::is_install_listing_request(&package) {
760        crate::commands::print_install_targets_help();
761        return Ok(());
762    }
763
764    let install_msg: String = format!("Installing package: {}", package);
765    let _ = log_info("install", &install_msg, None).await;
766    match ui::with_loader(
767        &format!("Installing package `{}`", package),
768        install_package(&package, force, debug),
769    )
770    .await
771    {
772        Ok(()) => {
773            let success_msg = format!("Successfully installed: {}", package);
774            let _ = log_success("install", &success_msg, None).await;
775            Ok(())
776        }
777        Err(e) => Err(e.into()),
778    }
779}
780
781pub(super) async fn handle_curl(cmd: commands::CurlCmd, debug: bool) -> CliResult<()> {
782    let url = cmd
783        .url
784        .unwrap_or_else(|| "https://example.com/api".to_string());
785    if let Err(e) = ui::with_loader(
786        &format!("Requesting {}", url),
787        curl::run_curl(&url, cmd.no_timeout, debug),
788    )
789    .await
790    {
791        let _ = log_error("curl", "Curl command failed", Some(&e)).await;
792        return Err(ErrorFactory::operation(
793            "curl",
794            "execute request",
795            e,
796            Some("Double-check the URL and network connectivity."),
797        ));
798    }
799    Ok(())
800}
801
802pub(super) async fn handle_services(debug: bool) -> CliResult<()> {
803    if let Err(e) = ui::with_loader("Loading configured services", list_services(debug)).await {
804        let _ = log_error("services", "Failed to list services", Some(&e)).await;
805        return Err(ErrorFactory::operation(
806            "services",
807            "list services",
808            e,
809            Some("Ensure xbp config is present and valid."),
810        ));
811    }
812    Ok(())
813}
814
815pub(super) async fn handle_service(
816    command: Option<String>,
817    service_name: Option<String>,
818    debug: bool,
819) -> CliResult<()> {
820    match (command.as_deref(), service_name.as_deref()) {
821        // Bare `xbp service` → preview + interactive picker (service then command)
822        (None, None) => {
823            if let Err(e) = run_service_interactive(debug).await {
824                let _ = log_error("service", "Interactive service run failed", Some(&e)).await;
825                return Err(ErrorFactory::operation(
826                    "service",
827                    "interactive picker",
828                    e,
829                    None,
830                ));
831            }
832        }
833        // `xbp service <cmd>` (no name) → pick service that supports the cmd
834        (Some(cmd), None) if cmd != "help" && cmd != "--help" => {
835            if let Err(e) = pick_service_for_command(cmd, debug).await {
836                let _ = log_error(
837                    "service",
838                    &format!("Service pick for '{}' failed", cmd),
839                    Some(&e),
840                )
841                .await;
842                return Err(ErrorFactory::operation(
843                    "service",
844                    &format!("pick service for {}", cmd),
845                    e,
846                    Some("Pass a service name explicitly, or ensure commands are defined in xbp config."),
847                ));
848            }
849        }
850        // `xbp service help <name>` or `xbp service --help <name>`
851        (Some(cmd), Some(name)) if cmd == "help" || cmd == "--help" => {
852            if let Err(e) = show_service_help(name).await {
853                let _ = log_error("service", "Failed to show service help", Some(&e)).await;
854                return Err(ErrorFactory::operation(
855                    "service",
856                    "show service help",
857                    e,
858                    None,
859                ));
860            }
861        }
862        // `xbp service <cmd> <name>` — classic direct execution
863        (Some(cmd), Some(name)) => {
864            if let Err(e) = run_service_command(cmd, name, debug).await {
865                let _ = log_error(
866                    "service",
867                    &format!("Service command '{}' failed", cmd),
868                    Some(&e),
869                )
870                .await;
871                return Err(ErrorFactory::operation(
872                    "service",
873                    &format!("run `{}` for `{}`", cmd, name),
874                    e,
875                    Some("Check available services via `xbp services`."),
876                ));
877            }
878        }
879        // `xbp service <name>` (no explicit command) — treat as show service info / help
880        (None, Some(name)) => {
881            if let Err(e) = show_service_help(name).await {
882                // fall back to usage if help fails (e.g. unknown service)
883                let _ = log_warn(
884                    "service",
885                    &format!("Could not show help for service '{}': {}", name, e),
886                    None,
887                )
888                .await;
889                print_service_usage();
890            }
891        }
892        // `xbp service help` (no name)
893        (Some(_), None) => {
894            print_service_usage();
895        }
896    }
897    Ok(())
898}
899
900pub(super) async fn handle_nginx(cmd: commands::NginxSubCommand, debug: bool) -> CliResult<()> {
901    if let Err(e) = run_nginx(cmd, debug).await {
902        let _ = log_error("nginx", "Nginx command failed", Some(&e.to_string())).await;
903        return Err(ErrorFactory::operation(
904            "nginx",
905            "execute nginx command",
906            e.to_string(),
907            Some("Try `xbp nginx --help` for command syntax."),
908        ));
909    }
910    Ok(())
911}
912
913pub(super) async fn handle_diag(cmd: commands::DiagCmd, debug: bool) -> CliResult<()> {
914    if let Err(e) = ui::with_loader("Running diagnostics", run_diag(cmd, debug)).await {
915        let _ = log_error("diag", "Diag command failed", Some(&e.to_string())).await;
916        return Err(ErrorFactory::operation(
917            "diag",
918            "run diagnostics",
919            e.to_string(),
920            Some("Re-run with `--debug` for more context."),
921        ));
922    }
923    Ok(())
924}
925
926pub(super) async fn handle_generate(cmd: commands::GenerateCmd, debug: bool) -> CliResult<()> {
927    match cmd.command {
928        commands::GenerateSubCommand::Config(subcmd) => {
929            let args = GenerateConfigArgs {
930                force: subcmd.force,
931                update: subcmd.update,
932                from_json: subcmd.from_json,
933            };
934            if let Err(e) = run_generate_config(args, debug).await {
935                let _ = log_error(
936                    "generate-config",
937                    "Failed to generate project config",
938                    Some(&e),
939                )
940                .await;
941                return Err(ErrorFactory::operation(
942                    "generate-config",
943                    "generate .xbp/xbp.yaml",
944                    e,
945                    Some("Use --update to refresh an existing config or --force to overwrite it."),
946                ));
947            }
948        }
949        #[cfg(feature = "openapi-gen")]
950        commands::GenerateSubCommand::Openapi(subcmd) => {
951            let args = GenerateOpenApiArgs {
952                services: subcmd.service,
953                all: subcmd.all,
954                aggregate: false,
955                format: subcmd.format,
956                output: subcmd.output,
957                check: subcmd.check,
958                dry_run: subcmd.dry_run,
959                strict: subcmd.strict,
960                no_cache: subcmd.no_cache,
961                version: None,
962            };
963            if let Err(e) = run_generate_openapi(args, debug).await {
964                let _ = log_error("generate-openapi", "Failed to generate OpenAPI", Some(&e)).await;
965                return Err(ErrorFactory::operation(
966                    "generate-openapi",
967                    "generate OpenAPI documents",
968                    e,
969                    Some("Check openapi settings in .xbp/xbp.yaml or use --strict for unresolved type details."),
970                ));
971            }
972        }
973        commands::GenerateSubCommand::Systemd(subcmd) => {
974            let args = GenerateSystemdArgs {
975                output_dir: subcmd.output_dir,
976                service: subcmd.service,
977                api: subcmd.api,
978            };
979            if let Err(e) = run_generate_systemd(args, debug).await {
980                let _ = log_error(
981                    "generate-systemd",
982                    "Failed to generate systemd units",
983                    Some(&e),
984                )
985                .await;
986                return Err(ErrorFactory::operation(
987                    "generate-systemd",
988                    "generate unit files",
989                    e,
990                    Some("Use a writable `--output-dir` or run with elevated permissions."),
991                ));
992            }
993        }
994    }
995    Ok(())
996}
997
998pub(super) async fn handle_done(cmd: commands::DoneCmd, _debug: bool) -> CliResult<()> {
999    if let Err(e) = crate::commands::run_done(
1000        cmd.root,
1001        cmd.since,
1002        cmd.output,
1003        cmd.no_ai,
1004        cmd.recursive,
1005        cmd.exclude,
1006    )
1007    .await
1008    {
1009        let _ = log_error("done", "Done command failed", Some(&e)).await;
1010        return Err(e.into());
1011    }
1012    Ok(())
1013}
1014
1015pub(super) async fn handle_fix_process_monitor_json(
1016    cmd: commands::FixProcessMonitorJsonCmd,
1017    _debug: bool,
1018) -> CliResult<()> {
1019    if let Err(error) =
1020        crate::commands::run_fix_process_monitor_json(cmd.path, cmd.check, cmd.stdout)
1021    {
1022        let _ = log_error(
1023            "fix-process-monitor-json",
1024            "Failed to repair process monitor JSON",
1025            Some(&error),
1026        )
1027        .await;
1028        return Err(error.into());
1029    }
1030    Ok(())
1031}
1032
1033pub(super) async fn handle_cursor(cmd: commands::CursorCmd) -> CliResult<()> {
1034    let result = match cmd.command {
1035        commands::CursorSubCommand::Ingest { dry_run } => run_cursor_ingest(dry_run).await,
1036    };
1037
1038    if let Err(error) = result {
1039        let _ = log_error("cursor", "Cursor command failed", Some(&error)).await;
1040        return Err(error.into());
1041    }
1042    Ok(())
1043}
1044
1045pub(super) async fn handle_login(cmd: commands::LoginCmd) -> CliResult<()> {
1046    let result = match cmd.action {
1047        Some(commands::LoginSubCommand::Status) => run_login_status().await,
1048        Some(commands::LoginSubCommand::Logout) => run_logout().await,
1049        None => run_login().await,
1050    };
1051
1052    if let Err(e) = result {
1053        let _ = log_error("login", "Login command failed", Some(&e)).await;
1054        return Err(e.into());
1055    }
1056
1057    Ok(())
1058}
1059
1060pub(super) async fn handle_whoami() -> CliResult<()> {
1061    if let Err(e) = run_whoami().await {
1062        let _ = log_error("whoami", "Whoami command failed", Some(&e)).await;
1063        return Err(e.into());
1064    }
1065
1066    Ok(())
1067}
1068
1069pub(super) async fn handle_version(cmd: commands::VersionCmd, debug: bool) -> CliResult<()> {
1070    if let Err(e) = require_authenticated_cli_session().await {
1071        return Err(ErrorFactory::operation(
1072            "version",
1073            "verify CLI login",
1074            e,
1075            Some("Run `xbp login` before using version workflows."),
1076        ));
1077    }
1078
1079    let commands::VersionCmd {
1080        target,
1081        explicit_version,
1082        git,
1083        command,
1084    } = cmd;
1085    let resolved_target = explicit_version.or(target);
1086
1087    if command.is_some() && (resolved_target.is_some() || git) {
1088        return Err(ErrorFactory::validation(
1089            "version",
1090            "`xbp version release` cannot be combined with `--git`, positional targets, or `--version`/`-v` on the parent command.",
1091            Some(
1092                "Run `xbp version release` as a standalone command, or use `xbp version --version <x.y.z>` without a subcommand.",
1093            ),
1094        ));
1095    }
1096
1097    if let Some(subcommand) = command {
1098        match subcommand {
1099            commands::VersionSubCommand::Release(release_cmd) => {
1100                let options = VersionReleaseOptions {
1101                    explicit_version: release_cmd.version,
1102                    release_flag: release_cmd.flag.map(|flag| flag.as_str().to_string()),
1103                    allow_dirty: release_cmd.allow_dirty,
1104                    title: release_cmd.title,
1105                    notes: release_cmd.notes,
1106                    notes_file: release_cmd.notes_file,
1107                    draft: release_cmd.draft,
1108                    prerelease: release_cmd.prerelease,
1109                    publish: release_cmd.publish,
1110                    force: release_cmd.force,
1111                    latest_policy: match release_cmd.make_latest {
1112                        commands::VersionReleaseLatest::True => ReleaseLatestPolicy::True,
1113                        commands::VersionReleaseLatest::False => ReleaseLatestPolicy::False,
1114                        commands::VersionReleaseLatest::Legacy => ReleaseLatestPolicy::Legacy,
1115                    },
1116                };
1117                if let Err(e) = run_version_release_command(options).await {
1118                    let hint = version_release_error_hint(&e);
1119                    return Err(ErrorFactory::operation(
1120                        "version",
1121                        "release version",
1122                        e,
1123                        hint,
1124                    ));
1125                }
1126                return Ok(());
1127            }
1128            commands::VersionSubCommand::Discover(discover_cmd) => {
1129                if let Err(e) = run_version_discover_services(&discover_cmd).await {
1130                    return Err(ErrorFactory::operation(
1131                        "version",
1132                        "discover and register project services",
1133                        e,
1134                        Some(
1135                            "Run `xbp version discover --dry-run` to preview package roots, or `--no-register` to opt out of writing.",
1136                        ),
1137                    ));
1138                }
1139                return Ok(());
1140            }
1141            commands::VersionSubCommand::Bump(bump_cmd) => {
1142                if let Err(e) = run_version_bump_command(&bump_cmd).await {
1143                    return Err(ErrorFactory::operation(
1144                        "version",
1145                        "bump mutated package versions",
1146                        e,
1147                        Some(
1148                            "Run `xbp version bump --dry-run` to preview candidates. On a clean tree every registered package is offered; use `--all --patch` (or `--minor`/`--major`) in non-interactive shells.",
1149                        ),
1150                    ));
1151                }
1152                return Ok(());
1153            }
1154            commands::VersionSubCommand::Domain(domain_cmd) => {
1155                let command = match domain_cmd.command {
1156                    commands::VersionDomainSubCommand::Init(init_cmd) => {
1157                        VersionDomainCommand::Init(VersionDomainInitOptions {
1158                            name: init_cmd.name,
1159                            root: init_cmd.root,
1160                            write: init_cmd.write,
1161                        })
1162                    }
1163                    commands::VersionDomainSubCommand::Doctor(doctor_cmd) => {
1164                        VersionDomainCommand::Doctor(VersionDomainDoctorOptions {
1165                            domain: doctor_cmd.domain,
1166                        })
1167                    }
1168                    commands::VersionDomainSubCommand::Sync(sync_cmd) => {
1169                        VersionDomainCommand::Sync(VersionDomainSyncOptions {
1170                            domain: sync_cmd.domain,
1171                            write: sync_cmd.write,
1172                        })
1173                    }
1174                    commands::VersionDomainSubCommand::Diagnose(diagnose_cmd) => {
1175                        VersionDomainCommand::Diagnose(VersionDomainDiagnoseOptions {
1176                            domain: diagnose_cmd.domain,
1177                            log: diagnose_cmd.log,
1178                        })
1179                    }
1180                    commands::VersionDomainSubCommand::Release(release_cmd) => {
1181                        VersionDomainCommand::Release(VersionDomainReleaseOptions {
1182                            domain: release_cmd.domain,
1183                            patch: release_cmd.patch,
1184                            minor: release_cmd.minor,
1185                            major: release_cmd.major,
1186                            version: release_cmd.version,
1187                            deploy: release_cmd.deploy,
1188                            allow_major_jump: release_cmd.allow_major_jump,
1189                            allow_cross_domain_version: release_cmd.allow_cross_domain_version,
1190                        })
1191                    }
1192                };
1193                if let Err(e) =
1194                    run_version_domain_command(VersionDomainCommandOptions { command }).await
1195                {
1196                    return Err(ErrorFactory::operation(
1197                        "version",
1198                        "run version-domain command",
1199                        e,
1200                        Some("Run `xbp version domain -h` to inspect supported usage."),
1201                    ));
1202                }
1203                return Ok(());
1204            }
1205            commands::VersionSubCommand::Workspace(workspace_cmd) => {
1206                let (repo, json, workspace_command) = match workspace_cmd.command {
1207                    commands::VersionWorkspaceSubCommand::Check(check_cmd) => (
1208                        check_cmd.target.repo,
1209                        check_cmd.target.json,
1210                        WorkspaceVersionCommand::Check(WorkspaceVersionCheckOptions {
1211                            version: check_cmd.version,
1212                        }),
1213                    ),
1214                    commands::VersionWorkspaceSubCommand::Sync(sync_cmd) => (
1215                        sync_cmd.target.repo,
1216                        sync_cmd.target.json,
1217                        WorkspaceVersionCommand::Sync(WorkspaceVersionSyncOptions {
1218                            version: sync_cmd.version,
1219                            write: sync_cmd.write,
1220                        }),
1221                    ),
1222                    commands::VersionWorkspaceSubCommand::Validate(validate_cmd) => (
1223                        validate_cmd.target.repo,
1224                        validate_cmd.target.json,
1225                        WorkspaceVersionCommand::Validate(WorkspaceVersionValidateOptions {
1226                            package: validate_cmd.package,
1227                            cargo_check: validate_cmd.cargo_check,
1228                            package_dry_run: validate_cmd.package_dry_run,
1229                        }),
1230                    ),
1231                    commands::VersionWorkspaceSubCommand::Publish(publish_cmd) => {
1232                        match publish_cmd.command {
1233                            commands::VersionWorkspacePublishSubCommand::Plan(plan_cmd) => (
1234                                plan_cmd.target.repo,
1235                                plan_cmd.target.json,
1236                                WorkspaceVersionCommand::PublishPlan(WorkspacePublishPlanOptions {
1237                                    only: plan_cmd.only,
1238                                    include_prereqs: plan_cmd.include_prereqs,
1239                                }),
1240                            ),
1241                            commands::VersionWorkspacePublishSubCommand::Run(run_cmd) => (
1242                                run_cmd.target.repo,
1243                                run_cmd.target.json,
1244                                WorkspaceVersionCommand::PublishRun(WorkspacePublishRunOptions {
1245                                    dry_run: run_cmd.dry_run,
1246                                    from: run_cmd.from,
1247                                    only: run_cmd.only,
1248                                    include_prereqs: run_cmd.include_prereqs,
1249                                    continue_on_error: run_cmd.continue_on_error,
1250                                    allow_dirty: run_cmd.allow_dirty,
1251                                    timeout_seconds: run_cmd.timeout_seconds,
1252                                    poll_interval_seconds: run_cmd.poll_interval_seconds,
1253                                }),
1254                            ),
1255                        }
1256                    }
1257                };
1258
1259                if let Err(e) = run_version_workspace_command(WorkspaceVersionCommandOptions {
1260                    repo,
1261                    json,
1262                    command: workspace_command,
1263                })
1264                .await
1265                {
1266                    return Err(ErrorFactory::operation(
1267                        "version",
1268                        "run workspace version command",
1269                        e,
1270                        Some("Run `xbp version workspace -h` to inspect supported usage."),
1271                    ));
1272                }
1273                return Ok(());
1274            }
1275        }
1276    }
1277
1278    if let Err(e) = run_version_command(resolved_target, git, debug).await {
1279        return Err(ErrorFactory::operation(
1280            "version",
1281            "run version command",
1282            e,
1283            Some("Run `xbp version -h` to inspect supported usage."),
1284        ));
1285    }
1286    Ok(())
1287}
1288
1289fn version_release_error_hint(error: &str) -> Option<&'static str> {
1290    if error.contains("Working tree is dirty") || error.contains("uncommitted changes") {
1291        return Some(
1292            "Use `--allow-dirty` if only generated files changed. `--force` also allows a dirty tree when `--publish` is enabled.",
1293        );
1294    }
1295    if error.contains("CARGO_REGISTRY_TOKEN") || error.contains("no token found") {
1296        return Some(
1297            "Configure a crates.io token with `xbp config crates set-key` or export `CARGO_REGISTRY_TOKEN`.",
1298        );
1299    }
1300    if error.contains("Workspace version drift detected") {
1301        return Some(
1302            "Run `xbp version workspace sync --write` to align crate versions, or pass `--force` to auto-sync during release.",
1303        );
1304    }
1305    if error.contains("Workspace closure detected")
1306        || error.contains("workspace publish command failed")
1307    {
1308        return Some(
1309            "For multi-crate workspaces, inspect the publish output and run `xbp version workspace publish plan --include-prereqs` first.",
1310        );
1311    }
1312    if error.contains("Duplicate port found") || error.contains("Duplicate service name") {
1313        return Some(
1314            "Duplicate service ports/names are deploy-time checks. Re-run `xbp version release` (release no longer blocks on them). Fix ports in `.xbp/xbp.yaml` before `xbp deploy`/`xbp start`.",
1315        );
1316    }
1317    if error.contains("Release cancelled") {
1318        return Some(
1319            "Re-run with `--allow-dirty` to skip dirty-tree prompts, or `--force` to auto-heal workspace versions and allow dirty publishes.",
1320        );
1321    }
1322    None
1323}
1324
1325fn should_print_help(cli: &Cli) -> bool {
1326    cli.command.is_none() && !cli.list && !cli.logs && !cli.commands && cli.port.is_none()
1327}
1328
1329fn background_cursor_ingest_trigger(command: Option<&commands::Commands>) -> Option<&'static str> {
1330    match command {
1331        None => None,
1332        Some(commands::Commands::Cursor(_)) => None,
1333        Some(commands::Commands::Diag(_)) => None,
1334        Some(commands::Commands::Whoami) => None,
1335        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1336            None => Some("login"),
1337            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1338        },
1339        Some(other) => Some(commands::command_label(other)),
1340    }
1341}
1342
1343fn background_system_inventory_refresh_trigger(
1344    command: Option<&commands::Commands>,
1345) -> Option<&'static str> {
1346    match command {
1347        None => None,
1348        Some(commands::Commands::Cursor(_)) => None,
1349        Some(commands::Commands::Diag(_)) => None,
1350        Some(commands::Commands::Whoami) => None,
1351        Some(commands::Commands::Login(login_cmd)) => match login_cmd.action {
1352            None => Some("login"),
1353            Some(commands::LoginSubCommand::Status | commands::LoginSubCommand::Logout) => None,
1354        },
1355        Some(other) => Some(commands::command_label(other)),
1356    }
1357}
1358
1359fn print_service_usage() {
1360    ui::configure_color_output();
1361    println!();
1362    println!("{}", "xbp service".bright_magenta().bold());
1363    ui::divider(28);
1364    println!(
1365        "{} {}",
1366        "Usage:".bright_cyan().bold(),
1367        "xbp service [command] [service-name]".bright_white()
1368    );
1369    println!();
1370    println!("Running with no arguments opens an interactive preview + picker.");
1371    ui::section("Commands");
1372    for command in ["build", "install", "start", "dev", "pre"] {
1373        println!("  {}", command.bright_cyan());
1374    }
1375    ui::section("Examples");
1376    println!(
1377        "  {}",
1378        "xbp service                      # interactive: pick service → command".dimmed()
1379    );
1380    println!(
1381        "  {}",
1382        "xbp service start                # pick from services that support start".dimmed()
1383    );
1384    println!("  {}", "xbp service build zeus".dimmed());
1385    ui::tip("Run `xbp service --help <service-name>` for service-specific guidance. All runs are recorded under $HOME/.xbp/logs/service/");
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use crate::cli::commands::{Commands, VersionReleaseLatest, VersionSubCommand};
1392
1393    #[test]
1394    fn plain_cli_invocation_prints_help() {
1395        let cli = Cli::try_parse_from(["xbp"]).expect("parse");
1396        assert!(should_print_help(&cli));
1397    }
1398
1399    #[test]
1400    fn root_version_short_v_displays_version() {
1401        let err = Cli::try_parse_from(["xbp", "-v"]).expect_err("version");
1402        assert_eq!(err.kind(), ClapErrorKind::DisplayVersion);
1403    }
1404
1405    #[test]
1406    fn root_version_capital_v_and_long_still_display_version() {
1407        let err_v = Cli::try_parse_from(["xbp", "-V"]).expect_err("version -V");
1408        assert_eq!(err_v.kind(), ClapErrorKind::DisplayVersion);
1409        let err_long = Cli::try_parse_from(["xbp", "--version"]).expect_err("version long");
1410        assert_eq!(err_long.kind(), ClapErrorKind::DisplayVersion);
1411    }
1412
1413    #[test]
1414    fn list_flag_skips_help_short_circuit() {
1415        let cli = Cli::try_parse_from(["xbp", "-l"]).expect("parse");
1416        assert!(!should_print_help(&cli));
1417    }
1418
1419    #[test]
1420    fn commands_flag_skips_help_short_circuit() {
1421        let cli = Cli::try_parse_from(["xbp", "--commands"]).expect("parse");
1422        assert!(!should_print_help(&cli));
1423        assert!(cli.commands);
1424    }
1425
1426    #[test]
1427    fn install_without_package_parses_and_is_optional() {
1428        let cli = Cli::try_parse_from(["xbp", "install"]).expect("parse");
1429        let Some(Commands::Install {
1430            list,
1431            force,
1432            package,
1433        }) = cli.command
1434        else {
1435            panic!("expected install command");
1436        };
1437        assert!(!list);
1438        assert!(!force);
1439        assert!(package.is_none());
1440    }
1441
1442    #[test]
1443    fn install_with_package_still_parses() {
1444        let cli = Cli::try_parse_from(["xbp", "install", "docker"]).expect("parse");
1445        let Some(Commands::Install {
1446            list,
1447            force,
1448            package,
1449        }) = cli.command
1450        else {
1451            panic!("expected install command");
1452        };
1453        assert!(!list);
1454        assert!(!force);
1455        assert_eq!(package.as_deref(), Some("docker"));
1456    }
1457
1458    #[test]
1459    fn install_force_flag_parses() {
1460        let cli = Cli::try_parse_from(["xbp", "install", "--force", "nx-brew"]).expect("parse");
1461        let Some(Commands::Install {
1462            list,
1463            force,
1464            package,
1465        }) = cli.command
1466        else {
1467            panic!("expected install command");
1468        };
1469        assert!(!list);
1470        assert!(force);
1471        assert_eq!(package.as_deref(), Some("nx-brew"));
1472    }
1473
1474    #[test]
1475    fn install_list_flag_parses() {
1476        let cli = Cli::try_parse_from(["xbp", "install", "--list"]).expect("parse");
1477        let Some(Commands::Install {
1478            list,
1479            force: _,
1480            package,
1481        }) = cli.command
1482        else {
1483            panic!("expected install command");
1484        };
1485        assert!(list);
1486        assert!(package.is_none());
1487    }
1488
1489    #[test]
1490    fn install_short_list_flag_parses() {
1491        let cli = Cli::try_parse_from(["xbp", "install", "-l"]).expect("parse");
1492        let Some(Commands::Install {
1493            list,
1494            force: _,
1495            package,
1496        }) = cli.command
1497        else {
1498            panic!("expected install command");
1499        };
1500        assert!(list);
1501        assert!(package.is_none());
1502    }
1503
1504    #[test]
1505    fn install_ls_alias_parses_as_package() {
1506        let cli = Cli::try_parse_from(["xbp", "install", "ls"]).expect("parse");
1507        let Some(Commands::Install {
1508            list,
1509            force: _,
1510            package,
1511        }) = cli.command
1512        else {
1513            panic!("expected install command");
1514        };
1515        assert!(!list);
1516        assert_eq!(package.as_deref(), Some("ls"));
1517    }
1518
1519    #[test]
1520    fn version_release_make_latest_parses_explicit_value() {
1521        let cli = Cli::try_parse_from([
1522            "xbp",
1523            "version",
1524            "release",
1525            "--version",
1526            "1.2.3",
1527            "--make-latest",
1528            "false",
1529        ])
1530        .expect("parse");
1531        let Some(Commands::Version(version_cmd)) = cli.command else {
1532            panic!("expected version command");
1533        };
1534        assert!(version_cmd.explicit_version.is_none());
1535        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1536            panic!("expected version release subcommand");
1537        };
1538        assert!(matches!(
1539            release_cmd.make_latest,
1540            VersionReleaseLatest::False
1541        ));
1542    }
1543
1544    #[test]
1545    fn version_release_make_latest_defaults_to_legacy() {
1546        let cli = Cli::try_parse_from(["xbp", "version", "release", "--version", "1.2.3"])
1547            .expect("parse");
1548        let Some(Commands::Version(version_cmd)) = cli.command else {
1549            panic!("expected version command");
1550        };
1551        let Some(VersionSubCommand::Release(release_cmd)) = version_cmd.command else {
1552            panic!("expected version release subcommand");
1553        };
1554        assert!(matches!(
1555            release_cmd.make_latest,
1556            VersionReleaseLatest::Legacy
1557        ));
1558    }
1559
1560    #[test]
1561    fn version_explicit_flag_parses() {
1562        let cli = Cli::try_parse_from(["xbp", "version", "--version", "1.2.3"]).expect("parse");
1563        let Some(Commands::Version(version_cmd)) = cli.command else {
1564            panic!("expected version command");
1565        };
1566        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1567        assert!(version_cmd.target.is_none());
1568        assert!(version_cmd.command.is_none());
1569    }
1570
1571    #[test]
1572    fn version_short_explicit_flag_parses_with_alias() {
1573        let cli = Cli::try_parse_from(["xbp", "v", "-v", "1.2.3"]).expect("parse");
1574        let Some(Commands::Version(version_cmd)) = cli.command else {
1575            panic!("expected version command");
1576        };
1577        assert_eq!(version_cmd.explicit_version.as_deref(), Some("1.2.3"));
1578        assert!(version_cmd.target.is_none());
1579        assert!(version_cmd.command.is_none());
1580    }
1581}