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