Skip to main content

zoi_cli/
cli.rs

1use std::io::{self};
2use std::path::PathBuf;
3
4use clap::builder::styling;
5use clap::{
6    ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueHint
7};
8use clap_complete::{Shell, generate};
9use colored::Colorize;
10use zoi_common::Runnable;
11
12use crate::pkg::lock;
13use crate::{cmd, utils};
14
15/// The ZFVM branch identifier of the current build (Prod, Dev, Spec, Pub).
16///
17/// This is the identifier, not the long-form name: the ZFVM Canonical Form is
18/// `Prod Beta 1.2.3`, never `Production Beta 1.2.3`. Long-form names are for
19/// display only and are mapped in `utils::format_version_summary`.
20///
21/// Updated by `scripts/bump.sh`, not by hand.
22const BRANCH: &str = "Prod";
23
24/// The ZFVM status of the current build (Pre-Alpha, Alpha, Beta, RC, Release).
25///
26/// Updated by `scripts/bump.sh`, not by hand.
27const STATUS: &str = "Release";
28
29/// The core version of the current build, as `X.Y.Z`.
30///
31/// Updated by `scripts/bump.sh`, not by hand.
32const NUMBER: &str = "1.29.3";
33/// Help text for package source identifiers.
34const PKG_SOURCE_HELP: &str =
35    "Package identifier (e.g. @repo/name, #git@repo/name, path, or URL)";
36
37/// Zoi - The Advanced Package Manager & Environment Orchestrator.
38///
39/// Part of the Zillowe Development Suite (ZDS), Zoi is designed to streamline
40/// your development workflow by managing tools and project environments.
41#[derive(Parser)]
42#[command(name = "zoi", author, about, long_about = None, disable_version_flag = true,
43    trailing_var_arg = true,
44    color = ColorChoice::Auto,
45    arg_required_else_help = true,
46)]
47pub struct Cli {
48    /// The subcommand to execute.
49    #[command(subcommand)]
50    command: Option<Commands>,
51
52    /// Print detailed version information.
53    #[arg(
54        short = 'v',
55        long = "version",
56        help = "Print detailed version information"
57    )]
58    version_flag: bool,
59
60    /// Automatically answer yes to all prompts.
61    #[arg(
62        short = 'y',
63        long,
64        help = "Automatically answer yes to all prompts",
65        global = true
66    )]
67    yes: bool,
68
69    /// Operate on a different root directory.
70    #[arg(
71        long = "root",
72        help = "Operate on a different root directory",
73        global = true,
74        value_hint = ValueHint::DirPath
75    )]
76    pub root: Option<std::path::PathBuf>,
77
78    /// Do not attempt to connect to the network.
79    #[arg(
80        long = "offline",
81        help = "Do not attempt to connect to the network",
82        global = true
83    )]
84    pub offline: bool,
85
86    /// Additional directory to search for .zpa archives.
87    #[arg(
88        long = "pkg-dir",
89        help = "Additional directory to search for .zpa archives",
90        global = true,
91        value_hint = ValueHint::DirPath
92    )]
93    /// Additional directory to search for .zpa archives.
94    pub pkg_dirs: Vec<std::path::PathBuf>
95}
96
97/// The target scope for system-level operations.
98#[derive(clap::ValueEnum, Clone, Debug, Copy, PartialEq, Eq)]
99pub enum SetupScope {
100    /// The current user's scope.
101    User,
102    /// The system-wide scope.
103    System
104}
105
106/// The target scope for package installation.
107#[derive(clap::ValueEnum, Clone, Debug, Copy)]
108pub enum InstallScope {
109    /// The current user's scope.
110    User,
111    /// The system-wide scope.
112    System,
113    /// The current project's scope.
114    Project
115}
116
117/// Subcommands for `zoi alt`, which manages alternative implementations.
118///
119/// Separate from the main command enum because `Alt` carries its own
120/// subcommand tree, and clap needs the nested variant declared here.
121#[derive(Subcommand)]
122pub enum AltCommand {
123    /// List every registered alternative
124    #[command(alias = "ls")]
125    List {
126        /// Show each group's candidates and their owners
127        #[arg(short, long)]
128        verbose: bool
129    },
130    /// Show one alternative in detail
131    Get {
132        /// Name of the alternative, e.g. awk
133        name: String
134    },
135    /// Select an implementation for an alternative
136    Set {
137        /// Name of the alternative, e.g. awk
138        name: String,
139        /// Path of the implementation to select, e.g. /usr/bin/mawk
140        path: String
141    },
142    /// Return to selecting the highest-priority implementation
143    Auto {
144        /// Name of the alternative, e.g. awk
145        name: String
146    },
147    /// Remove one implementation from an alternative
148    Remove {
149        /// Name of the alternative, e.g. awk
150        name: String,
151        /// Path of the implementation to remove
152        path: String
153    }
154}
155
156/// The available subcommands for Zoi.
157#[derive(Subcommand)]
158enum Commands {
159    /// Generates shell completion scripts
160    #[command(hide = true)]
161    GenerateCompletions {
162        /// The shell to generate completions for
163        #[arg(value_enum)]
164        shell: Shell
165    },
166
167    /// Dynamic shell completions (internal use)
168    #[command(hide = true)]
169    Complete {
170        /// The shell to complete for
171        #[arg(value_enum)]
172        shell: Shell,
173        /// Current word index (1-based)
174        index: usize,
175        /// All words in the command line
176        words: Vec<String>
177    },
178
179    /// Prints concise version and build information
180    #[command(
181        alias = "v",
182        long_about = "Displays the version number, build status, branch, and \
183                      commit hash. This is the same output provided by the -v \
184                      and --version flags."
185    )]
186    Version,
187
188    /// Shows detailed application information and credits
189    #[command(long_about = "Displays the full application name, description, \
190                            author, license, and homepage information.")]
191    About,
192
193    /// Displays detected operating system and architecture information
194    #[command(long_about = "Detects and displays key system details, \
195                            including the OS, CPU architecture, Linux \
196                            distribution (if applicable), and available \
197                            package managers.")]
198    Info,
199
200    /// Downloads a package archive or source bundle
201    #[command(
202        alias = "dl",
203        long_about = "Downloads the binary archive (.zpa) or source bundle \
204                      (.zsa) for a package to the local cache or a specified \
205                      directory."
206    )]
207    Download {
208        /// Package identifier (e.g. @repo/name, path, or URL)
209        #[arg(value_name = "PACKAGE", required = true, help = PKG_SOURCE_HELP)]
210        package: String,
211
212        /// Download the binary archive (.zpa) [default]
213        #[arg(long, group = "type")]
214        archive: bool,
215
216        /// Download the source bundle (.zsa)
217        #[arg(long, group = "type")]
218        source: bool,
219
220        /// Directory to output the downloaded file to
221        #[arg(short, long)]
222        output_dir: Option<PathBuf>
223    },
224
225    /// Downloads or updates the package database from the remote repository
226    #[command(
227        alias = "sy",
228        long_about = "Clones the official package database from GitLab to \
229                      Zoi's user data directory. If the database already \
230                      exists, it verifies the remote URL and pulls the latest \
231                      changes."
232    )]
233    Sync {
234        /// The sync subcommand to execute.
235        #[command(subcommand)]
236        command: Option<SyncCommands>,
237
238        /// Show the full git output
239        #[arg(short, long)]
240        verbose: bool,
241
242        /// Fallback to other mirrors if the default one fails
243        #[arg(long)]
244        fallback: bool,
245
246        /// Do not check for installed package managers
247        #[arg(long = "no-pm")]
248        no_package_managers: bool,
249
250        /// Force re-sync by removing existing databases and re-cloning from
251        /// scratch
252        #[arg(long)]
253        force: bool,
254
255        /// Sync registries to the project's local .zoi/pkgs/db/ using
256        /// revisions from zoi.lua
257        #[arg(long)]
258        local: bool,
259
260        /// When used with --local, sync using revisions from zoi.lock instead
261        /// of zoi.lua
262        #[arg(long)]
263        frozen: bool,
264
265        /// The scope to sync the registries to
266        #[arg(long, value_enum, conflicts_with = "local")]
267        scope: Option<SetupScope>
268    },
269
270    /// Migration helpers for converting external manifests to Zoi package files
271    Migrate(cmd::migrate::MigrateCommand),
272
273    /// Lists installed or all available packages
274    #[command(alias = "ls")]
275    List {
276        /// List all packages from the database, not just installed ones
277        #[arg(short, long)]
278        all: bool,
279        /// List only installed packages that have updates available
280        #[arg(short, long)]
281        outdated: bool,
282        /// Filter by registry handle (e.g. 'zoidberg')
283        #[arg(long)]
284        registry: Option<String>,
285        /// Filter by repository (e.g. 'main', 'extra')
286        #[arg(long)]
287        repo: Option<String>,
288        /// Filter by package type (package, app, collection, extension)
289        #[arg(short = 't', long = "type")]
290        package_type: Option<String>,
291        /// List packages not found in any configured registry
292        #[arg(short = 'm', long)]
293        foreign: bool,
294        /// List only package names (internal use for completions)
295        #[arg(long, hide = true)]
296        names: bool,
297        /// List packages with descriptions for completion
298        #[arg(long, hide = true)]
299        completion: bool
300    },
301
302    /// Shows detailed information about a package
303    Show {
304        /// The package identifier.
305        #[arg(value_name = "ALL_PACKAGES", help = PKG_SOURCE_HELP)]
306        package_name: String,
307        /// Display the raw, unformatted package file
308        #[arg(long)]
309        raw: bool,
310        /// Use PURL (Package URL) specification for resolving package
311        #[arg(long)]
312        purl: bool
313    },
314
315    /// Pin a package to a specific version
316    Pin {
317        /// The package identifier.
318        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
319        package: String,
320        /// The version to pin the package to
321        version: String
322    },
323
324    /// Find which package provides a specific command or file
325    Provides {
326        /// The command or file path to search for
327        term: String
328    },
329
330    /// Visualize the dependency tree of a package
331    Tree {
332        /// The package identifier(s).
333        #[arg(value_name = "ALL_PACKAGES", required = true, help = PKG_SOURCE_HELP)]
334        packages: Vec<String>
335    },
336
337    /// Unpin a package, allowing it to be updated
338    Unpin {
339        /// The package identifier.
340        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
341        package: String
342    },
343
344    /// Modify the installation reason of a package
345    #[command(
346        alias = "m",
347        long_about = "Changes whether a package is considered explicitly installed or a dependency. Explicit packages are not removed by 'autoremove', while dependencies are if no other package requires them.",
348        group(clap::ArgGroup::new("mode").required(true).args(["as_dependency", "as_explicit"]))
349    )]
350    Mark {
351        /// The package identifier(s).
352        #[arg(value_name = "INST_PACKAGES", required = true, help = PKG_SOURCE_HELP)]
353        packages: Vec<String>,
354
355        /// Mark packages as dependencies
356        #[arg(long, aliases = ["asdeps"])]
357        as_dependency: bool,
358
359        /// Mark packages as explicitly installed
360        #[arg(long, aliases = ["asexpl"], conflicts_with = "as_dependency")]
361        as_explicit: bool
362    },
363
364    /// Updates one or more packages to their latest versions
365    #[command(
366        alias = "up",
367        arg_required_else_help = true,
368        group(clap::ArgGroup::new("target").required(true).args(["package_names", "all"]))
369    )]
370    Update {
371        /// The package names to update.
372        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
373        package_names: Vec<String>,
374
375        /// Update all installed packages
376        #[arg(long, conflicts_with = "package_names")]
377        all: bool,
378
379        /// Do not actually perform the update, just show what would be done
380        #[arg(long)]
381        dry_run: bool,
382        /// Explain why each selected update is included or skipped
383        #[arg(long)]
384        explain: bool,
385        /// Emit machine-readable update plan JSON
386        #[arg(long, requires = "dry_run")]
387        plan_json: bool,
388        /// Show additional update details
389        #[arg(long, short)]
390        verbose: bool,
391        /// Interactively choose which upgradable packages to update (with
392        /// --all)
393        #[arg(long, requires = "all")]
394        interactive: bool
395    },
396
397    /// Installs one or more packages from a name, local file, URL, or git
398    /// repository
399    #[command(aliases = ["i", "in", "add"])]
400    Install(cmd::install::args::InstallArgs),
401
402    /// Uninstalls one or more packages previously installed by Zoi
403    #[command(
404        aliases = ["un", "rm", "remove"],
405        long_about = "Removes one or more packages' files from the Zoi store and deletes their symlinks from the bin directory. This command will fail if a package was not installed by Zoi."
406    )]
407    Uninstall(cmd::uninstall::args::UninstallArgs),
408
409    /// Execute a command defined in a local zoi.lua file
410    #[command(long_about = "Execute a command from zoi.lua. If no command is \
411                            specified, it will launch an interactive prompt \
412                            to choose one.")]
413    Run {
414        /// The alias of the command to execute
415        cmd_alias: Option<String>,
416        /// Arguments to pass to the command
417        args: Vec<String>
418    },
419
420    /// Manage and set up project environments from a local zoi.lua file
421    #[command(long_about = "Checks for required packages and runs setup \
422                            commands for a defined environment. If no \
423                            environment is specified, it launches an \
424                            interactive prompt.")]
425    Env {
426        /// The alias of the environment to set up
427        env_alias: Option<String>,
428
429        /// Export environment variables for the current shell
430        #[arg(long, value_enum, hide = true)]
431        export_shell: Option<Shell>
432    },
433
434    /// Enter a development shell for the current project
435    #[command(
436        alias = "develop",
437        long_about = "Loads the project configuration from zoi.lua, ensures \
438                      all required packages are installed locally, sets up \
439                      environment variables (PATH, LD_LIBRARY_PATH, etc.), \
440                      and drops you into a subshell."
441    )]
442    Dev {
443        /// Command to run in the dev shell instead of an interactive shell
444        #[arg(short, long)]
445        run: Option<String>,
446        /// Temporary clone a repository and enter its development shell
447        #[arg(long)]
448        repo: Option<String>
449    },
450
451    /// Upgrades the Zoi binary to the latest version
452    #[command(
453        alias = "ug",
454        long_about = "Upgrades Zoi to the latest version. By default, it \
455                      attempts a delta upgrade (bsdiff) to minimize download \
456                      size. If the delta upgrade is unavailable or fails, it \
457                      automatically falls back to a full download."
458    )]
459    Upgrade {
460        /// Force a full download instead of a delta upgrade
461        #[arg(long)]
462        force: bool,
463
464        /// Upgrade to a specific git tag
465        #[arg(long)]
466        tag: Option<String>,
467
468        /// Upgrade to the latest release of a specific branch (e.g. Prod, Pub)
469        #[arg(long)]
470        branch: Option<String>
471    },
472
473    /// Removes packages that were installed as dependencies but are no longer
474    /// needed
475    Autoremove {
476        /// Do not actually remove packages, just show what would be done
477        #[arg(long)]
478        dry_run: bool
479    },
480
481    /// Explains why a package is installed
482    Why {
483        /// The package identifier.
484        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
485        package_name: String
486    },
487
488    /// Find which package owns a file
489    #[command(alias = "owns")]
490    Owner {
491        /// Path to the file
492        #[arg(value_hint = ValueHint::FilePath)]
493        path: std::path::PathBuf
494    },
495
496    /// List all files owned by a package
497    Files {
498        /// The package identifier.
499        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
500        package: String
501    },
502
503    /// Inspect and choose alternative implementations
504    ///
505    /// For commands with several interchangeable implementations, such as awk,
506    /// vi or editor. Packages register alternatives and Zoi keeps the canonical
507    /// path pointing at the one with the highest priority, which can be
508    /// overridden per system without reinstalling anything.
509    #[command(subcommand, name = "alt")]
510    Alt(AltCommand),
511
512    /// Shows the history of package operations
513    History {
514        /// Verify audit log chain integrity instead of printing history
515        /// entries
516        #[arg(long, conflicts_with = "export")]
517        verify: bool,
518        /// Export audit history to a file (default format: JSON array with
519        /// chain fields)
520        #[arg(long, value_hint = ValueHint::FilePath, conflicts_with = "verify")]
521        export: Option<std::path::PathBuf>,
522        /// Export in newline-delimited JSON (ndjson) instead of a JSON array
523        #[arg(long, requires = "export")]
524        ndjson: bool
525    },
526
527    /// Searches for packages by name or description
528    #[command(
529        alias = "s",
530        long_about = "Searches for a case-insensitive term in the name, \
531                      description, and tags of all available packages in the \
532                      database. Filter by repo, type, or tags."
533    )]
534    Search {
535        /// The term to search for (e.g. 'editor', 'cli')
536        search_term: String,
537        /// Filter by registry handle (e.g. 'zoidberg')
538        #[arg(long)]
539        registry: Option<String>,
540        /// Filter by repository (e.g. 'main', 'extra')
541        #[arg(long)]
542        repo: Option<String>,
543        /// Filter by package type (package, app, collection, extension)
544        #[arg(long = "type")]
545        package_type: Option<String>,
546        /// Filter by tags (any match). Multiple via comma or repeated -t
547        #[arg(short = 't', long = "tag", value_delimiter = ',', num_args = 1..)]
548        tags: Option<Vec<String>>,
549        /// Sort results by field (name, repo, type)
550        #[arg(long, default_value = "name")]
551        sort: String,
552        /// Search for files provided by packages instead of package names
553        #[arg(short, long)]
554        files: bool,
555        /// Open results in an interactive TUI
556        #[arg(short = 'i', long)]
557        interactive: bool
558    },
559
560    /// Manage background services for installed packages
561    #[command(alias = "svc")]
562    Service(cmd::service::ServiceCommand),
563
564    /// Set up shell completions or enter an ephemeral environment with specific
565    /// packages
566    #[command(
567        long_about = "If a shell is provided, it installs completion scripts. If 'hook' is provided, it outputs shell-specific hook scripts for auto-activation. If packages are provided via --package/-p, it enters a temporary subshell with those packages available in PATH.",
568        arg_required_else_help = true,
569        group(clap::ArgGroup::new("shell_action").required(true).args(["shell", "hook", "packages"]).multiple(true))
570    )]
571    Shell {
572        /// The shell to set up completions for
573        #[arg(value_enum)]
574        shell: Option<Shell>,
575        /// Generate a shell hook for automatic environment activation
576        #[arg(long)]
577        hook: bool,
578        /// The scope to apply the setup to (user or system-wide)
579        #[arg(long, value_enum, default_value = "user")]
580        scope: SetupScope,
581        /// Packages to include in the ephemeral environment
582        #[arg(short, long = "package", value_name = "ALL_PACKAGES")]
583        packages: Vec<String>,
584        /// Command to run in the ephemeral environment instead of an
585        /// interactive shell
586        #[arg(short, long)]
587        run: Option<String>,
588        /// Show additional details (resolution, installation progress, etc.)
589        #[arg(long, short)]
590        verbose: bool
591    },
592
593    /// Execute a package binary directly with its dependencies resolved
594    #[command(
595        alias = "x",
596        long_about = "Resolves a package and its dependencies, installs them \
597                      if needed, then runs the requested binary directly. By \
598                      default runs the first binary the package provides. \
599                      Uses bwrap for sandboxed packages."
600    )]
601    Exec {
602        /// The package source identifier.
603        #[arg(value_name = "ALL_SOURCES", help = PKG_SOURCE_HELP)]
604        source: String,
605
606        /// Specific binary to run (required if package provides multiple
607        /// binaries)
608        #[arg(long)]
609        bin: Option<String>,
610
611        /// Show additional execution details
612        #[arg(long, short)]
613        verbose: bool,
614
615        /// Arguments to pass to the executed binary
616        #[arg(value_name = "ARGS")]
617        args: Vec<String>
618    },
619
620    /// Clones the git repository of a package
621    Clone {
622        /// The package identifier (e.g. @repo/name, path, or URL)
623        #[arg(value_name = "ALL_PACKAGES", required = true, help = PKG_SOURCE_HELP)]
624        package: String,
625        /// The location to clone the repository to
626        #[arg(value_name = "LOCATION")]
627        location: Option<String>
628    },
629
630    /// Manage Zoi's local cache
631    Cache {
632        /// The cache subcommand to execute.
633        #[command(subcommand)]
634        command: CacheCommands
635    },
636
637    /// Inspect recorded transactions
638    #[command(alias = "tx")]
639    Transaction {
640        /// The transaction subcommand to execute.
641        #[command(subcommand)]
642        command: TransactionCommands
643    },
644
645    /// Manage and author Zoi registries
646    #[command(alias = "reg")]
647    Registry(cmd::registry::RegistryCommand),
648
649    /// Manage declarative user environments (`ZoiOS` only)
650    Home(cmd::home::HomeCommand),
651
652    /// Manage the underlying `ZoiOS` system (`ZoiOS` only)
653    System(cmd::system::SystemCommand),
654
655    /// Manage package repositories
656    #[command(
657        aliases = ["repositories"],
658        long_about = "Manages the list of package repositories used by Zoi.\n\nCommands:\n- add (alias: a): Add an official repo by name or clone from a git URL.\n- remove|rm: Remove a repo from active list (repo rm <name>).\n- list|ls: Show active repositories by default; use 'list all' to show all available repositories.\n- git: Manage cloned git repositories (git ls, git rm <repo-name>)."
659    )]
660    Repo(cmd::repo::RepoCommand),
661
662    /// Manage telemetry settings (opt-in analytics)
663    #[command(long_about = "Manage opt-in anonymous telemetry used to \
664                            understand package popularity. Default is \
665                            disabled.")]
666    Telemetry {
667        /// The telemetry subcommand to execute.
668        #[command(subcommand)]
669        command: TelemetrySubcommand
670    },
671
672    /// Create a zoi.lua project file in the current directory
673    Init {
674        /// Overwrite an existing zoi.lua
675        #[arg(short, long)]
676        force: bool
677    },
678
679    /// Create an application using a package template
680    Create {
681        /// The package source identifier.
682        #[arg(value_name = "ALL_SOURCES", help = PKG_SOURCE_HELP)]
683        source: String,
684        /// The application name to substitute into template commands
685        app_name: Option<String>
686    },
687
688    /// Downgrade a package to a specific version from local cache or store
689    #[command(
690        alias = "dg",
691        long_about = "Interactively choose and install an older version of a \
692                      package from the local store or archive cache. This is \
693                      useful if a recent update has introduced bugs or \
694                      compatibility issues."
695    )]
696    Downgrade {
697        /// The package identifier.
698        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
699        package: String
700    },
701
702    /// Manage Zoi extensions
703    #[command(alias = "ext")]
704    Extension(ExtensionCommand),
705
706    /// Rollback a package to the previously installed version
707    Rollback {
708        /// The package identifier.
709        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
710        package: String
711    },
712
713    /// Shows a package's manual
714    Man {
715        /// The package identifier.
716        #[arg(value_name = "ALL_PACKAGES", help = PKG_SOURCE_HELP)]
717        package_name: String,
718        /// Always look at the upstream manual even if it's downloaded
719        #[arg(long)]
720        upstream: bool,
721        /// Print the manual to the terminal raw
722        #[arg(long)]
723        raw: bool,
724        /// Show a specific manual page (e.g. 'zbsdiff.1' or 'zbsdiff.3').
725        /// Defaults to the page matching the package name in section 1.
726        #[arg(long)]
727        page: Option<String>
728    },
729
730    /// Build, create, and manage Zoi packages
731    #[command(alias = "pkg")]
732    Package(cmd::package::PackageCommand),
733
734    /// Manage PGP keys for package signature verification
735    Pgp(cmd::pgp::PgpCommand),
736
737    /// Helper commands for various tasks
738    Helper(cmd::helper::HelperCommand),
739
740    /// Checks for common issues and provides actionable suggestions
741    Doctor,
742
743    /// Audit installed or all packages for security vulnerabilities
744    Audit {
745        /// Show all vulnerabilities from the database, not just for installed
746        /// packages
747        #[arg(short, long)]
748        all: bool,
749        /// Filter by registry handle
750        #[arg(long)]
751        registry: Option<String>,
752        /// Filter by repository
753        #[arg(long)]
754        repo: Option<String>
755    },
756
757    /// Execute an external subcommand.
758    #[command(external_subcommand)]
759    External(Vec<String>)
760}
761
762/// The extension management command.
763#[derive(clap::Parser, Debug)]
764pub struct ExtensionCommand {
765    /// The extension subcommand to execute.
766    #[command(subcommand)]
767    pub command: ExtensionCommands
768}
769
770/// The available extension subcommands.
771#[derive(clap::Subcommand, Debug)]
772pub enum ExtensionCommands {
773    /// Add an extension
774    Add {
775        /// The name of the extension to add
776        #[arg(required = true)]
777        name: String
778    },
779    /// Remove an extension
780    Remove {
781        /// The name of the extension to remove
782        #[arg(required = true)]
783        name: String
784    }
785}
786
787/// The available sync subcommands.
788#[derive(clap::Subcommand, Clone)]
789pub enum SyncCommands {
790    /// Add a new registry
791    Add {
792        /// URL of the registry to add
793        url: String
794    },
795    /// Remove a configured registry by its handle
796    Remove {
797        /// Handle of the registry to remove
798        handle: String
799    },
800    /// List configured registries
801    #[command(alias = "ls")]
802    List,
803    /// Set the default registry URL
804    Set {
805        /// URL or keyword (default, github, gitlab, codeberg)
806        url: String
807    }
808}
809
810/// The available cache management subcommands.
811#[derive(clap::Subcommand)]
812pub enum CacheCommands {
813    /// Add package archive(s) to the local cache
814    Add {
815        /// Path to the .zpa archive(s)
816        #[arg(required = true)]
817        files: Vec<std::path::PathBuf>
818    },
819    /// Clear the entire Zoi cache
820    #[command(alias = "clean")]
821    Clear {
822        /// Do not actually clear the cache, just show what would be done
823        #[arg(long)]
824        dry_run: bool
825    },
826    /// List all archives currently in the cache
827    #[command(alias = "ls")]
828    List,
829    /// Manage cache mirrors used for archive downloads
830    Mirror {
831        /// The cache mirror subcommand to execute.
832        #[command(subcommand)]
833        command: CacheMirrorCommands
834    }
835}
836
837/// The available cache mirror management subcommands.
838#[derive(clap::Subcommand)]
839pub enum CacheMirrorCommands {
840    /// Add a cache mirror base URL
841    Add {
842        /// Mirror base URL
843        url: String
844    },
845    /// Remove a cache mirror base URL
846    Remove {
847        /// Mirror base URL
848        url: String
849    },
850    /// List configured cache mirrors
851    #[command(alias = "ls")]
852    List
853}
854
855/// The available transaction management subcommands.
856#[derive(clap::Subcommand)]
857pub enum TransactionCommands {
858    /// List known transaction logs
859    #[command(alias = "ls")]
860    List,
861    /// Show details for a transaction
862    Show {
863        /// Transaction ID
864        id: String
865    },
866    /// List modified files for a transaction
867    Files {
868        /// Transaction ID
869        id: String
870    },
871    /// Undo a transaction by reverting all of its recorded operations.
872    /// Defaults to the most recent transaction when no ID is given.
873    #[command(alias = "rollback")]
874    Undo {
875        /// Transaction ID (defaults to the last transaction)
876        id: Option<String>,
877
878        /// Do not prompt for confirmation
879        #[arg(long)]
880        yes: bool
881    }
882}
883
884/// The available actions for telemetry.
885#[derive(clap::Subcommand, Clone)]
886pub enum TelemetrySubcommand {
887    /// Show the current telemetry status.
888    Status,
889    /// Enable anonymous telemetry.
890    Enable,
891    /// Disable anonymous telemetry.
892    Disable,
893    /// Manage local crash reports (`.zoicrash` files in
894    /// `$XDG_STATE_HOME/zoi/crash`).
895    Crash {
896        /// The crash-report action to perform.
897        #[command(subcommand)]
898        command: CrashSubcommand
899    },
900    /// Deliver queued analytics events (internal: spawned detached after
901    /// commands that enqueue telemetry, never runs on the hot path).
902    #[command(hide = true)]
903    Flush
904}
905
906/// The available crash-report actions.
907#[derive(clap::Subcommand, Clone)]
908pub enum CrashSubcommand {
909    /// List locally stored crash reports.
910    #[command(alias = "ls")]
911    List,
912    /// Print a crash report envelope to stdout.
913    Show {
914        /// Crash report file name or full path.
915        file: PathBuf
916    },
917    /// Upload a crash report to Sentry (requires telemetry opt-in).
918    Send {
919        /// Crash report file name or full path.
920        file: PathBuf,
921        /// Skip the confirmation prompt.
922        #[arg(long)]
923        yes: bool
924    },
925    /// Delete all locally stored crash reports.
926    Clear {
927        /// Skip the confirmation prompt.
928        #[arg(long)]
929        yes: bool
930    }
931}
932
933/// Returns the analytics name for a subcommand.
934fn command_name(command: &Commands) -> &'static str {
935    match command {
936        Commands::GenerateCompletions { .. } => "generate-completions",
937        Commands::Complete { .. } => "complete",
938        Commands::Version => "version",
939        Commands::About => "about",
940        Commands::Info => "info",
941        Commands::Download { .. } => "download",
942        Commands::Sync { .. } => "sync",
943        Commands::Migrate(_) => "migrate",
944        Commands::List { .. } => "list",
945        Commands::Show { .. } => "show",
946        Commands::Pin { .. } => "pin",
947        Commands::Provides { .. } => "provides",
948        Commands::Tree { .. } => "tree",
949        Commands::Unpin { .. } => "unpin",
950        Commands::Mark { .. } => "mark",
951        Commands::Update { .. } => "update",
952        Commands::Install(_) => "install",
953        Commands::Uninstall(_) => "uninstall",
954        Commands::Run { .. } => "run",
955        Commands::Env { .. } => "env",
956        Commands::Dev { .. } => "dev",
957        Commands::Upgrade { .. } => "upgrade",
958        Commands::Autoremove { .. } => "autoremove",
959        Commands::Why { .. } => "why",
960        Commands::Owner { .. } => "owner",
961        Commands::Files { .. } => "files",
962        Commands::Alt(_) => "alt",
963        Commands::History { .. } => "history",
964        Commands::Search { .. } => "search",
965        Commands::Service(_) => "service",
966        Commands::Shell { .. } => "shell",
967        Commands::Exec { .. } => "exec",
968        Commands::Clone { .. } => "clone",
969        Commands::Cache { .. } => "cache",
970        Commands::Transaction { .. } => "transaction",
971        Commands::Repo(_) => "repo",
972        Commands::Registry(_) => "registry",
973        Commands::Home(_) => "home",
974        Commands::System { .. } => "system",
975        Commands::Telemetry { .. } => "telemetry",
976        Commands::Create { .. } => "create",
977        Commands::Init { .. } => "init",
978        Commands::Downgrade { .. } => "downgrade",
979        Commands::Extension(_) => "extension",
980        Commands::Rollback { .. } => "rollback",
981        Commands::Man { .. } => "man",
982        Commands::Package(_) => "package",
983        Commands::Pgp(_) => "pgp",
984        Commands::Helper(_) => "helper",
985        Commands::Doctor => "doctor",
986        Commands::Audit { .. } => "audit",
987        Commands::External(_) => "external"
988    }
989}
990
991/// The main entry point for the Zoi CLI.
992///
993/// # Errors
994///
995/// Returns an error if argument parsing fails, plugin loading fails, or if any
996/// subcommand fails.
997pub fn run() -> anyhow::Result<()> {
998    let styles = styling::Styles::styled()
999        .header(
1000            styling::AnsiColor::Yellow.on_default() | styling::Effects::BOLD
1001        )
1002        .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
1003        .literal(styling::AnsiColor::Green.on_default())
1004        .placeholder(styling::AnsiColor::Cyan.on_default());
1005
1006    let commit: &str = option_env!("ZOI_COMMIT_HASH").unwrap_or("dev");
1007    let cmd = Cli::command().styles(styles.clone());
1008    let matches = cmd.clone().get_matches();
1009    let cli = match Cli::from_arg_matches(&matches) {
1010        Ok(cli) => cli,
1011        Err(err) => {
1012            err.print()?;
1013            return Err(anyhow::anyhow!("Failed to parse arguments"));
1014        }
1015    };
1016
1017    if let Some(root) = cli.root {
1018        crate::pkg::sysroot::set_sysroot(root);
1019    }
1020
1021    let config = crate::pkg::config::read_config().unwrap_or_default();
1022
1023    let is_offline = cli.offline || config.offline_mode;
1024    crate::pkg::offline::set_offline(is_offline);
1025
1026    // - Always installs the local panic hook so crashes land in
1027    //   `$XDG_STATE_HOME/zoi/crash/*.zoicrash`.
1028    // - Initializes Sentry only when opted-in with a DSN.
1029    // - Prompts to upload pending crashes on interactive TTYs when opted-in,
1030    //   except for telemetry-management commands: opting out (or inspecting
1031    //   crashes) must never be gated behind an upload prompt for potentially
1032    //   sensitive reports.
1033    let prompt_pending_crashes =
1034        !matches!(cli.command, Some(Commands::Telemetry { .. }));
1035    let _telemetry_guards =
1036        zoi_telemetry::init_telemetry(prompt_pending_crashes);
1037
1038    let mut all_pkg_dirs = cli.pkg_dirs;
1039    for dir in config.pkg_dirs {
1040        let path = std::path::PathBuf::from(dir);
1041        if !all_pkg_dirs.contains(&path) {
1042            all_pkg_dirs.push(path);
1043        }
1044    }
1045    crate::pkg::pkgdir::set_pkg_dirs(all_pkg_dirs);
1046
1047    utils::check_path();
1048
1049    if let Err(e) = crate::pkg::pgp::ensure_builtin_keys() {
1050        eprintln!(
1051            "{}: Failed to ensure builtin PGP keys: {}",
1052            "Warning".yellow(),
1053            e
1054        );
1055    }
1056
1057    let plugin_manager = crate::pkg::plugin::PluginManager::new()?;
1058    if let Err(e) = plugin_manager.load_all(cli.yes) {
1059        eprintln!("{}: Failed to load plugins: {}", "Warning".yellow(), e);
1060    }
1061
1062    if cli.version_flag {
1063        cmd::version::run(BRANCH, STATUS, NUMBER, commit);
1064        return Ok(());
1065    }
1066
1067    if let Some(command) = cli.command {
1068        let command_name = command_name(&command);
1069        // - The detached queue flusher delivers; it must never enqueue (and
1070        //   re-spawn) itself. The library reentrancy guard
1071        //   (`ZOI_TELEMETRY_FLUSHING`) backs this up.
1072        let is_flush_command = matches!(
1073            command,
1074            Commands::Telemetry {
1075                command: TelemetrySubcommand::Flush
1076            }
1077        );
1078        // Exposed to the crash reporter so panic envelopes can tag the
1079        // in-flight command. Set before dispatch, cleared afterwards.
1080        // SAFETY: Set at startup before worker threads do telemetry work.
1081        unsafe {
1082            std::env::set_var("ZOI_CRASH_COMMAND", command_name);
1083        }
1084
1085        let needs_lock = matches!(
1086            command,
1087            Commands::Install { .. }
1088                | Commands::Uninstall { .. }
1089                | Commands::Update { .. }
1090                | Commands::Autoremove { .. }
1091                | Commands::Rollback { .. }
1092                | Commands::Package(_)
1093        );
1094
1095        let _lock_guard = if needs_lock {
1096            Some(lock::acquire_lock()?)
1097        } else {
1098            None
1099        };
1100
1101        let result = match command {
1102            Commands::GenerateCompletions { shell } => {
1103                let mut cmd = Cli::command();
1104                let bin_name = cmd.get_name().to_string();
1105                generate(shell, &mut cmd, bin_name, &mut io::stdout());
1106                Ok(())
1107            }
1108            Commands::Complete {
1109                shell,
1110                index,
1111                words
1112            } => cmd::complete::run(shell, index, &words),
1113            Commands::Version => {
1114                cmd::version::run(BRANCH, STATUS, NUMBER, commit);
1115                Ok(())
1116            }
1117            Commands::About => {
1118                cmd::about::run(BRANCH, STATUS, NUMBER, commit);
1119                Ok(())
1120            }
1121            Commands::Info => cmd::info::run(BRANCH, STATUS, NUMBER, commit),
1122            Commands::Sync {
1123                command,
1124                verbose,
1125                fallback,
1126                no_package_managers,
1127                force,
1128                local,
1129                frozen,
1130                scope
1131            } => {
1132                if let Some(cmd) = command {
1133                    match cmd {
1134                        SyncCommands::Add { url } => {
1135                            cmd::sync::add_registry(&url)
1136                        }
1137                        SyncCommands::Remove { handle } => {
1138                            cmd::sync::remove_registry(&handle)
1139                        }
1140                        SyncCommands::List => cmd::sync::list_registries(),
1141                        SyncCommands::Set { url } => {
1142                            cmd::sync::set_registry(&url)
1143                        }
1144                    }
1145                } else if local {
1146                    plugin_manager.trigger_hook("on_pre_sync", None)?;
1147                    let res =
1148                        cmd::sync::run_local(verbose, fallback, force, frozen);
1149                    plugin_manager.trigger_hook_nonfatal("on_post_sync", None);
1150                    res
1151                } else {
1152                    plugin_manager.trigger_hook("on_pre_sync", None)?;
1153                    let res = cmd::sync::run(
1154                        verbose,
1155                        fallback,
1156                        no_package_managers,
1157                        force,
1158                        scope
1159                    );
1160                    plugin_manager.trigger_hook_nonfatal("on_post_sync", None);
1161                    res
1162                }
1163            }
1164            Commands::Migrate(args) => cmd::migrate::run(args),
1165            Commands::List {
1166                all,
1167                outdated,
1168                registry,
1169                repo,
1170                package_type,
1171                foreign,
1172                names,
1173                completion
1174            } => cmd::list::run(
1175                all,
1176                outdated,
1177                registry.as_deref(),
1178                repo.as_deref(),
1179                package_type.as_deref(),
1180                foreign,
1181                names,
1182                completion
1183            ),
1184            Commands::Show {
1185                package_name,
1186                raw,
1187                purl
1188            } => cmd::show::run(&package_name, raw, purl),
1189            Commands::Pin { package, version } => {
1190                cmd::pin::run(&package, &version, cli.yes)
1191            }
1192            Commands::Provides { term } => cmd::provides::run(&term),
1193            Commands::Tree { packages } => cmd::tree::run(&packages),
1194            Commands::Unpin { package } => cmd::unpin::run(&package, cli.yes),
1195            Commands::Mark {
1196                packages,
1197                as_dependency,
1198                as_explicit
1199            } => cmd::mark::run(&packages, as_dependency, as_explicit),
1200            Commands::Update {
1201                package_names,
1202                all,
1203                dry_run,
1204                explain,
1205                plan_json,
1206                verbose,
1207                interactive
1208            } => cmd::update::run(
1209                all,
1210                &package_names,
1211                cli.yes,
1212                dry_run,
1213                explain,
1214                plan_json,
1215                verbose,
1216                interactive
1217            )
1218            .map_err(|e| cmd::ux::with_failure_hint("update", e)),
1219            Commands::Install(args) => args
1220                .run(cli.yes)
1221                .map_err(|e| cmd::ux::with_failure_hint("install", e)),
1222            Commands::Uninstall(args) => args
1223                .run(cli.yes)
1224                .map_err(|e| cmd::ux::with_failure_hint("uninstall", e)),
1225            Commands::Run { cmd_alias, args } => {
1226                cmd::run::run(cmd_alias.as_deref(), &args)
1227            }
1228            Commands::Env {
1229                env_alias,
1230                export_shell
1231            } => cmd::env::run(env_alias.as_deref(), export_shell),
1232            Commands::Dev { run, repo } => cmd::dev::run(run, repo),
1233            Commands::Upgrade { force, tag, branch } => {
1234                match cmd::upgrade::run(
1235                    BRANCH, STATUS, NUMBER, force, tag, branch
1236                ) {
1237                    Ok(()) => {
1238                        println!(
1239                            "\n{}",
1240                            "Zoi upgraded successfully! Please restart your \
1241                             shell for changes to take effect."
1242                                .green()
1243                        );
1244                        println!(
1245                            "\n{}: https://github.com/zillowe/zoi/blob/main/CHANGELOG.md",
1246                            "Changelog".cyan().bold()
1247                        );
1248                        println!(
1249                            "\n{}: To update shell completions, run 'zoi \
1250                             shell <your-shell>'.",
1251                            "Hint".cyan().bold()
1252                        );
1253                    }
1254                    Err(e) if e.to_string() == "already_on_latest" => {}
1255                    Err(e) if e.to_string() == "managed_by_package_manager" => {
1256                    }
1257                    Err(e) => return Err(e)
1258                }
1259                Ok(())
1260            }
1261            Commands::Autoremove { dry_run } => {
1262                cmd::autoremove::run(cli.yes, dry_run)
1263            }
1264            Commands::Why { package_name } => cmd::why::run(&package_name),
1265            Commands::Owner { path } => cmd::owner::run(&path),
1266            Commands::Alt(ref args) => cmd::alt::run(args),
1267            Commands::Files { package } => cmd::files::run(&package),
1268            Commands::History {
1269                verify,
1270                export,
1271                ndjson
1272            } => cmd::history::run(verify, export, ndjson),
1273            Commands::Search {
1274                search_term,
1275                registry,
1276                repo,
1277                package_type,
1278                tags,
1279                sort,
1280                files,
1281                interactive
1282            } => cmd::search::run(
1283                &search_term,
1284                registry.as_deref(),
1285                repo.as_deref(),
1286                package_type.as_deref(),
1287                tags,
1288                &sort,
1289                files,
1290                interactive
1291            ),
1292            Commands::Service(args) => cmd::service::run(args),
1293            Commands::Shell {
1294                shell,
1295                hook,
1296                scope,
1297                packages,
1298                run,
1299                verbose
1300            } => {
1301                let target_shell = shell
1302                    .or_else(crate::pkg::utils::get_current_shell)
1303                    .unwrap_or(Shell::Bash);
1304                if hook {
1305                    cmd::shell::print_hook(target_shell)
1306                } else if !packages.is_empty() {
1307                    cmd::shell::enter_ephemeral_shell(
1308                        &packages,
1309                        run,
1310                        verbose,
1311                        Some(&plugin_manager)
1312                    )
1313                } else {
1314                    cmd::shell::run(target_shell, scope)
1315                }
1316            }
1317            Commands::Exec {
1318                source,
1319                bin,
1320                verbose,
1321                args
1322            } => cmd::exec::run(&source, bin, &args, verbose),
1323            Commands::Download {
1324                package,
1325                archive: _,
1326                source,
1327                output_dir
1328            } => {
1329                let download_type = if source {
1330                    cmd::download::DownloadType::Source
1331                } else {
1332                    cmd::download::DownloadType::Archive
1333                };
1334                cmd::download::run(&package, download_type, output_dir)
1335            }
1336            Commands::Clone { package, location } => {
1337                cmd::clone::run(&package, location, cli.yes)
1338            }
1339            Commands::Cache { command } => match command {
1340                CacheCommands::Add { files } => cmd::cache::add(&files),
1341                CacheCommands::Clear { dry_run } => cmd::cache::clear(dry_run),
1342                CacheCommands::List => cmd::cache::list(),
1343                CacheCommands::Mirror { command } => match command {
1344                    CacheMirrorCommands::Add { url } => {
1345                        cmd::cache::add_mirror(&url)
1346                    }
1347                    CacheMirrorCommands::Remove { url } => {
1348                        cmd::cache::remove_mirror(&url)
1349                    }
1350                    CacheMirrorCommands::List => cmd::cache::list_mirrors()
1351                }
1352            },
1353            Commands::Transaction { command } => match command {
1354                TransactionCommands::List => cmd::transaction::list(),
1355                TransactionCommands::Show { id } => cmd::transaction::show(&id),
1356                TransactionCommands::Files { id } => {
1357                    cmd::transaction::files(&id)
1358                }
1359                TransactionCommands::Undo { id, yes } => {
1360                    cmd::transaction::undo(id.as_deref(), yes, cli.yes)
1361                }
1362            },
1363            Commands::Repo(args) => cmd::repo::run(args),
1364            Commands::Registry(args) => cmd::registry::run(args),
1365            Commands::Home(args) => cmd::home::run(args),
1366            Commands::System(args) => cmd::system::run(args, cli.yes),
1367            Commands::Telemetry { command } => {
1368                use cmd::telemetry::{CrashCommand, TelemetryCommand, run};
1369                let cmd = match command {
1370                    TelemetrySubcommand::Status => TelemetryCommand::Status,
1371                    TelemetrySubcommand::Enable => TelemetryCommand::Enable,
1372                    TelemetrySubcommand::Disable => TelemetryCommand::Disable,
1373                    TelemetrySubcommand::Flush => TelemetryCommand::Flush,
1374                    TelemetrySubcommand::Crash { command } => {
1375                        TelemetryCommand::Crash(match command {
1376                            CrashSubcommand::List => CrashCommand::List,
1377                            CrashSubcommand::Show { file } => {
1378                                CrashCommand::Show { file }
1379                            }
1380                            CrashSubcommand::Send { file, yes } => {
1381                                CrashCommand::Send {
1382                                    file,
1383                                    yes: yes || cli.yes
1384                                }
1385                            }
1386                            CrashSubcommand::Clear { yes } => {
1387                                CrashCommand::Clear {
1388                                    yes: yes || cli.yes
1389                                }
1390                            }
1391                        })
1392                    }
1393                };
1394                run(cmd)
1395            }
1396            Commands::Create { source, app_name } => cmd::create::run(
1397                cmd::create::CreateCommand { source, app_name },
1398                cli.yes,
1399                Some(&plugin_manager)
1400            ),
1401            Commands::Init { force } => cmd::init::run(cli.yes, force),
1402            Commands::Downgrade { package } => {
1403                cmd::downgrade::run(&package, cli.yes, Some(&plugin_manager))
1404            }
1405            Commands::Extension(args) => {
1406                cmd::extension::run(args, cli.yes, Some(&plugin_manager))
1407            }
1408            Commands::Rollback { package } => {
1409                cmd::rollback::run(&package, cli.yes, Some(&plugin_manager))
1410            }
1411            Commands::Man {
1412                package_name,
1413                upstream,
1414                raw,
1415                page
1416            } => cmd::man::run(&package_name, upstream, raw, page.as_deref()),
1417            Commands::Package(args) => cmd::package::run(args),
1418            Commands::Pgp(args) => cmd::pgp::run(args),
1419            Commands::Helper(args) => cmd::helper::run(args),
1420            Commands::Doctor => cmd::doctor::run(),
1421            Commands::Audit {
1422                all,
1423                registry,
1424                repo
1425            } => cmd::audit::run(all, registry, repo.as_deref()),
1426            Commands::External(args) => {
1427                let (cmd_name, cmd_args) =
1428                    if let Some((first, rest)) = args.split_first() {
1429                        (first, rest.to_vec())
1430                    } else {
1431                        return Err(anyhow::anyhow!("No command specified"));
1432                    };
1433
1434                match plugin_manager.run_command(cmd_name, cmd_args) {
1435                    Ok(true) => Ok(()),
1436                    Ok(false) => {
1437                        let mut shadow_cmd = Cli::command().styles(styles);
1438                        shadow_cmd =
1439                            shadow_cmd.allow_external_subcommands(false);
1440
1441                        let err = shadow_cmd
1442                            .clone()
1443                            .try_get_matches_from(std::env::args())
1444                            .err()
1445                            .unwrap_or_else(|| {
1446                                shadow_cmd.error(
1447                                    clap::error::ErrorKind::InvalidSubcommand,
1448                                    format!(
1449                                        "unrecognized subcommand '{cmd_name}'"
1450                                    )
1451                                )
1452                            });
1453
1454                        let plugin_cmds = plugin_manager.list_commands()?;
1455                        if !plugin_cmds.is_empty() {
1456                            eprintln!(
1457                                "{}:",
1458                                "Available Plugin Commands".cyan().bold()
1459                            );
1460                            for (pcmd, pdesc) in plugin_cmds {
1461                                if pdesc.is_empty() {
1462                                    eprintln!("  {pcmd}");
1463                                } else {
1464                                    eprintln!(
1465                                        "  {:<12} {}",
1466                                        pcmd,
1467                                        pdesc.dimmed()
1468                                    );
1469                                }
1470                            }
1471                            eprintln!();
1472                        }
1473
1474                        err.exit();
1475                    }
1476                    Err(e) => Err(e)
1477                }
1478            }
1479        };
1480
1481        // SAFETY: Paired with the `set_var` above; no telemetry worker reads
1482        // this after dispatch completes.
1483        unsafe {
1484            std::env::remove_var("ZOI_CRASH_COMMAND");
1485        }
1486
1487        match &result {
1488            Ok(()) => {
1489                // Daily active-user ping (at most one per 24h, no command
1490                // identity attached). Queued to disk with a detached
1491                // flusher, so this never blocks on the network. Failures
1492                // are deliberately excluded here; they go to Sentry below.
1493                // Telemetry failures must never break the CLI, so the
1494                // outcome is ignored.
1495                if !is_flush_command {
1496                    let _ = zoi_telemetry::posthog_capture_dau_ping();
1497                }
1498            }
1499            Err(e) => {
1500                zoi_telemetry::sentry_capture_error(command_name, e.as_ref());
1501            }
1502        }
1503
1504        if let Err(e) = result {
1505            eprintln!("Error: {e}");
1506            // Flush queued Sentry events (e.g. the failure report above)
1507            // before exiting: `process::exit` skips destructors, so without
1508            // this the telemetry guard would never drain the transport queue.
1509            // Bounded to ~2s inside `shutdown_sentry`, then the process exits
1510            // regardless of delivery.
1511            zoi_telemetry::shutdown_sentry();
1512            std::process::exit(1);
1513        }
1514    }
1515    Ok(())
1516}