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