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.25.5";
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                      your local machine (~/.zoi/pkgs/db). If the database \
184                      already exists, it verifies the remote URL and pulls \
185                      the latest 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    /// Clears the cache of downloaded package binaries
578    Clean {
579        /// Do not actually clear the cache, just show what would be done
580        #[arg(long)]
581        dry_run: bool
582    },
583
584    /// Clones the git repository of a package
585    Clone {
586        /// The package identifier (e.g. @repo/name, path, or URL)
587        #[arg(value_name = "ALL_PACKAGES", required = true, help = PKG_SOURCE_HELP)]
588        package: String,
589        /// The location to clone the repository to
590        #[arg(value_name = "LOCATION")]
591        location: Option<String>
592    },
593
594    /// Manage Zoi's local cache
595    Cache {
596        /// The cache subcommand to execute.
597        #[command(subcommand)]
598        command: CacheCommands
599    },
600
601    /// Inspect recorded transactions
602    #[command(alias = "tx")]
603    Transaction {
604        /// The transaction subcommand to execute.
605        #[command(subcommand)]
606        command: TransactionCommands
607    },
608
609    /// Manage and author Zoi registries
610    #[command(alias = "reg")]
611    Registry(cmd::registry::RegistryCommand),
612
613    /// Manage declarative user environments (`ZoiOS` only)
614    Home(cmd::home::HomeCommand),
615
616    /// Manage the underlying `ZoiOS` system (`ZoiOS` only)
617    System(cmd::system::SystemCommand),
618
619    /// Manage package repositories
620    #[command(
621        aliases = ["repositories"],
622        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>)."
623    )]
624    Repo(cmd::repo::RepoCommand),
625
626    /// Manage telemetry settings (opt-in analytics)
627    #[command(long_about = "Manage opt-in anonymous telemetry used to \
628                            understand package popularity. Default is \
629                            disabled.")]
630    Telemetry {
631        /// The telemetry action to perform.
632        #[arg(value_enum)]
633        action: TelemetryAction
634    },
635
636    /// Create an application using a package template
637    Create {
638        /// The package source identifier.
639        #[arg(value_name = "ALL_SOURCES", help = PKG_SOURCE_HELP)]
640        source: String,
641        /// The application name to substitute into template commands
642        app_name: Option<String>
643    },
644
645    /// Downgrade a package to a specific version from local cache or store
646    #[command(
647        alias = "dg",
648        long_about = "Interactively choose and install an older version of a \
649                      package from the local store or archive cache. This is \
650                      useful if a recent update has introduced bugs or \
651                      compatibility issues."
652    )]
653    Downgrade {
654        /// The package identifier.
655        #[arg(value_name = "INST_PACKAGES", help = PKG_SOURCE_HELP)]
656        package: String
657    },
658
659    /// Manage Zoi extensions
660    #[command(alias = "ext")]
661    Extension(ExtensionCommand),
662
663    /// Rollback a package to the previously installed version
664    Rollback {
665        /// The package identifier.
666        #[arg(value_name = "INST_PACKAGES", required_unless_present = "last_transaction", help = PKG_SOURCE_HELP)]
667        package: Option<String>,
668
669        /// Rollback the last transaction
670        #[arg(long, conflicts_with = "package")]
671        last_transaction: bool
672    },
673
674    /// Shows a package's manual
675    Man {
676        /// The package identifier.
677        #[arg(value_name = "ALL_PACKAGES", help = PKG_SOURCE_HELP)]
678        package_name: String,
679        /// Always look at the upstream manual even if it's downloaded
680        #[arg(long)]
681        upstream: bool,
682        /// Print the manual to the terminal raw
683        #[arg(long)]
684        raw: bool,
685        /// Do not use the TUI, use the system pager instead
686        #[arg(long)]
687        no_tui: bool
688    },
689
690    /// Build, create, and manage Zoi packages
691    #[command(alias = "pkg")]
692    Package(cmd::package::PackageCommand),
693
694    /// Manage PGP keys for package signature verification
695    Pgp(cmd::pgp::PgpCommand),
696
697    /// Helper commands for various tasks
698    Helper(cmd::helper::HelperCommand),
699
700    /// Checks for common issues and provides actionable suggestions
701    Doctor,
702
703    /// Audit installed or all packages for security vulnerabilities
704    Audit {
705        /// Show all vulnerabilities from the database, not just for installed
706        /// packages
707        #[arg(short, long)]
708        all: bool,
709        /// Filter by registry handle
710        #[arg(long)]
711        registry: Option<String>,
712        /// Filter by repository
713        #[arg(long)]
714        repo: Option<String>
715    },
716
717    /// Execute an external subcommand.
718    #[command(external_subcommand)]
719    External(Vec<String>)
720}
721
722/// The extension management command.
723#[derive(clap::Parser, Debug)]
724pub struct ExtensionCommand {
725    /// The extension subcommand to execute.
726    #[command(subcommand)]
727    pub command: ExtensionCommands
728}
729
730/// The available extension subcommands.
731#[derive(clap::Subcommand, Debug)]
732pub enum ExtensionCommands {
733    /// Add an extension
734    Add {
735        /// The name of the extension to add
736        #[arg(required = true)]
737        name: String
738    },
739    /// Remove an extension
740    Remove {
741        /// The name of the extension to remove
742        #[arg(required = true)]
743        name: String
744    }
745}
746
747/// The available sync subcommands.
748#[derive(clap::Subcommand, Clone)]
749pub enum SyncCommands {
750    /// Add a new registry
751    Add {
752        /// URL of the registry to add
753        url: String
754    },
755    /// Remove a configured registry by its handle
756    Remove {
757        /// Handle of the registry to remove
758        handle: String
759    },
760    /// List configured registries
761    #[command(alias = "ls")]
762    List,
763    /// Set the default registry URL
764    Set {
765        /// URL or keyword (default, github, gitlab, codeberg)
766        url: String
767    }
768}
769
770/// The available cache management subcommands.
771#[derive(clap::Subcommand)]
772pub enum CacheCommands {
773    /// Add package archive(s) to the local cache
774    Add {
775        /// Path to the .zpa archive(s)
776        #[arg(required = true)]
777        files: Vec<std::path::PathBuf>
778    },
779    /// Clear the local cache
780    #[command(alias = "clean")]
781    Clear {
782        /// Do not actually clear the cache, just show what would be done
783        #[arg(long)]
784        dry_run: bool
785    },
786    /// List all archives currently in the cache
787    #[command(alias = "ls")]
788    List,
789    /// Manage cache mirrors used for archive downloads
790    Mirror {
791        /// The cache mirror subcommand to execute.
792        #[command(subcommand)]
793        command: CacheMirrorCommands
794    }
795}
796
797/// The available cache mirror management subcommands.
798#[derive(clap::Subcommand)]
799pub enum CacheMirrorCommands {
800    /// Add a cache mirror base URL
801    Add {
802        /// Mirror base URL
803        url: String
804    },
805    /// Remove a cache mirror base URL
806    Remove {
807        /// Mirror base URL
808        url: String
809    },
810    /// List configured cache mirrors
811    #[command(alias = "ls")]
812    List
813}
814
815/// The available transaction management subcommands.
816#[derive(clap::Subcommand)]
817pub enum TransactionCommands {
818    /// List known transaction logs
819    #[command(alias = "ls")]
820    List,
821    /// Show details for a transaction
822    Show {
823        /// Transaction ID
824        id: String
825    },
826    /// List modified files for a transaction
827    Files {
828        /// Transaction ID
829        id: String
830    }
831}
832
833/// The available actions for telemetry.
834#[derive(clap::ValueEnum, Clone)]
835enum TelemetryAction {
836    /// Show the current telemetry status.
837    Status,
838    /// Enable anonymous telemetry.
839    Enable,
840    /// Disable anonymous telemetry.
841    Disable
842}
843
844/// The main entry point for the Zoi CLI.
845///
846/// # Errors
847///
848/// Returns an error if argument parsing fails, plugin loading fails, or if any
849/// subcommand fails.
850pub fn run() -> anyhow::Result<()> {
851    let styles = styling::Styles::styled()
852        .header(
853            styling::AnsiColor::Yellow.on_default() | styling::Effects::BOLD
854        )
855        .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
856        .literal(styling::AnsiColor::Green.on_default())
857        .placeholder(styling::AnsiColor::Cyan.on_default());
858
859    let commit: &str = option_env!("ZOI_COMMIT_HASH").unwrap_or("dev");
860    let cmd = Cli::command().styles(styles.clone());
861    let matches = cmd.clone().get_matches();
862    let cli = match Cli::from_arg_matches(&matches) {
863        Ok(cli) => cli,
864        Err(err) => {
865            err.print()?;
866            return Err(anyhow::anyhow!("Failed to parse arguments"));
867        }
868    };
869
870    if let Some(root) = cli.root {
871        crate::pkg::sysroot::set_sysroot(root);
872    }
873
874    let config = crate::pkg::config::read_config().unwrap_or_default();
875
876    let is_offline = cli.offline || config.offline_mode;
877    crate::pkg::offline::set_offline(is_offline);
878
879    let mut all_pkg_dirs = cli.pkg_dirs;
880    for dir in config.pkg_dirs {
881        let path = std::path::PathBuf::from(dir);
882        if !all_pkg_dirs.contains(&path) {
883            all_pkg_dirs.push(path);
884        }
885    }
886    crate::pkg::pkgdir::set_pkg_dirs(all_pkg_dirs);
887
888    utils::check_path();
889
890    if let Err(e) = crate::pkg::pgp::ensure_builtin_keys() {
891        eprintln!(
892            "{}: Failed to ensure builtin PGP keys: {}",
893            "Warning".yellow(),
894            e
895        );
896    }
897
898    let plugin_manager = crate::pkg::plugin::PluginManager::new()?;
899    if let Err(e) = plugin_manager.load_all(cli.yes) {
900        eprintln!("{}: Failed to load plugins: {}", "Warning".yellow(), e);
901    }
902
903    if cli.version_flag {
904        cmd::version::run(BRANCH, STATUS, NUMBER, commit);
905        return Ok(());
906    }
907
908    if let Some(command) = cli.command {
909        let needs_lock = matches!(
910            command,
911            Commands::Install { .. }
912                | Commands::Uninstall { .. }
913                | Commands::Update { .. }
914                | Commands::Autoremove { .. }
915                | Commands::Rollback { .. }
916                | Commands::Package(_)
917        );
918
919        let _lock_guard = if needs_lock {
920            Some(lock::acquire_lock()?)
921        } else {
922            None
923        };
924
925        let result = match command {
926            Commands::GenerateCompletions { shell } => {
927                let mut cmd = Cli::command();
928                let bin_name = cmd.get_name().to_string();
929                generate(shell, &mut cmd, bin_name, &mut io::stdout());
930                Ok(())
931            }
932            Commands::Complete {
933                shell,
934                index,
935                words
936            } => cmd::complete::run(shell, index, &words),
937            Commands::GenerateManual => cmd::gen_man::run().map_err(Into::into),
938            Commands::Version => {
939                cmd::version::run(BRANCH, STATUS, NUMBER, commit);
940                Ok(())
941            }
942            Commands::About => {
943                cmd::about::run(BRANCH, STATUS, NUMBER, commit);
944                Ok(())
945            }
946            Commands::Info => cmd::info::run(BRANCH, STATUS, NUMBER, commit),
947            Commands::Sync {
948                command,
949                verbose,
950                fallback,
951                no_package_managers,
952                force,
953                local,
954                frozen,
955                scope
956            } => {
957                if let Some(cmd) = command {
958                    match cmd {
959                        SyncCommands::Add { url } => {
960                            cmd::sync::add_registry(&url)
961                        }
962                        SyncCommands::Remove { handle } => {
963                            cmd::sync::remove_registry(&handle)
964                        }
965                        SyncCommands::List => cmd::sync::list_registries(),
966                        SyncCommands::Set { url } => {
967                            cmd::sync::set_registry(&url)
968                        }
969                    }
970                } else if local {
971                    plugin_manager.trigger_hook("on_pre_sync", None)?;
972                    let res =
973                        cmd::sync::run_local(verbose, fallback, force, frozen);
974                    plugin_manager.trigger_hook_nonfatal("on_post_sync", None);
975                    res
976                } else {
977                    plugin_manager.trigger_hook("on_pre_sync", None)?;
978                    let res = cmd::sync::run(
979                        verbose,
980                        fallback,
981                        no_package_managers,
982                        force,
983                        scope
984                    );
985                    plugin_manager.trigger_hook_nonfatal("on_post_sync", None);
986                    res
987                }
988            }
989            Commands::Migrate(args) => cmd::migrate::run(args),
990            Commands::List {
991                all,
992                outdated,
993                registry,
994                repo,
995                package_type,
996                foreign,
997                names,
998                completion
999            } => cmd::list::run(
1000                all,
1001                outdated,
1002                registry.as_deref(),
1003                repo.as_deref(),
1004                package_type.as_deref(),
1005                foreign,
1006                names,
1007                completion
1008            ),
1009            Commands::Show {
1010                package_name,
1011                raw,
1012                purl
1013            } => cmd::show::run(&package_name, raw, purl),
1014            Commands::Pin { package, version } => {
1015                cmd::pin::run(&package, &version)
1016            }
1017            Commands::Provides { term } => cmd::provides::run(&term),
1018            Commands::Tree { packages } => cmd::tree::run(&packages),
1019            Commands::Unpin { package } => cmd::unpin::run(&package),
1020            Commands::Mark {
1021                packages,
1022                as_dependency,
1023                as_explicit
1024            } => cmd::mark::run(&packages, as_dependency, as_explicit),
1025            Commands::Update {
1026                package_names,
1027                all,
1028                dry_run,
1029                explain,
1030                plan_json,
1031                verbose,
1032                interactive
1033            } => cmd::update::run(
1034                all,
1035                &package_names,
1036                cli.yes,
1037                dry_run,
1038                explain,
1039                plan_json,
1040                verbose,
1041                interactive
1042            )
1043            .map_err(|e| cmd::ux::with_failure_hint("update", e)),
1044            Commands::Install(args) => args
1045                .run(cli.yes)
1046                .map_err(|e| cmd::ux::with_failure_hint("install", e)),
1047            Commands::Use { packages, global } => {
1048                cmd::use_cmd::run(&packages, global)
1049            }
1050            Commands::Uninstall(args) => args
1051                .run(cli.yes)
1052                .map_err(|e| cmd::ux::with_failure_hint("uninstall", e)),
1053            Commands::Run { cmd_alias, args } => {
1054                cmd::run::run(cmd_alias.as_deref(), &args)
1055            }
1056            Commands::Env {
1057                env_alias,
1058                export_shell
1059            } => cmd::env::run(env_alias.as_deref(), export_shell),
1060            Commands::Dev { run, repo } => cmd::dev::run(run, repo),
1061            Commands::Upgrade { force, tag, branch } => {
1062                match cmd::upgrade::run(
1063                    BRANCH, STATUS, NUMBER, force, tag, branch
1064                ) {
1065                    Ok(()) => {
1066                        println!(
1067                            "\n{}",
1068                            "Zoi upgraded successfully! Please restart your \
1069                             shell for changes to take effect."
1070                                .green()
1071                        );
1072                        println!(
1073                            "\n{}: https://github.com/zillowe/zoi/blob/main/CHANGELOG.md",
1074                            "Changelog".cyan().bold()
1075                        );
1076                        println!(
1077                            "\n{}: To update shell completions, run 'zoi \
1078                             shell <your-shell>'.",
1079                            "Hint".cyan().bold()
1080                        );
1081                    }
1082                    Err(e) if e.to_string() == "already_on_latest" => {}
1083                    Err(e) if e.to_string() == "managed_by_package_manager" => {
1084                    }
1085                    Err(e) => return Err(e)
1086                }
1087                Ok(())
1088            }
1089            Commands::Autoremove { dry_run } => {
1090                cmd::autoremove::run(cli.yes, dry_run)
1091            }
1092            Commands::Why { package_name } => cmd::why::run(&package_name),
1093            Commands::Owner { path } => cmd::owner::run(&path),
1094            Commands::Files { package } => cmd::files::run(&package),
1095            Commands::History {
1096                verify,
1097                export,
1098                ndjson
1099            } => cmd::history::run(verify, export, ndjson),
1100            Commands::Search {
1101                search_term,
1102                registry,
1103                repo,
1104                package_type,
1105                tags,
1106                sort,
1107                files,
1108                interactive
1109            } => cmd::search::run(
1110                &search_term,
1111                registry.as_deref(),
1112                repo.as_deref(),
1113                package_type.as_deref(),
1114                tags,
1115                &sort,
1116                files,
1117                interactive
1118            ),
1119            Commands::Service(args) => cmd::service::run(args),
1120            Commands::Shell {
1121                shell,
1122                hook,
1123                scope,
1124                packages,
1125                run,
1126                verbose
1127            } => {
1128                let target_shell = shell
1129                    .or_else(crate::pkg::utils::get_current_shell)
1130                    .unwrap_or(Shell::Bash);
1131                if hook {
1132                    cmd::shell::print_hook(target_shell)
1133                } else if !packages.is_empty() {
1134                    cmd::shell::enter_ephemeral_shell(
1135                        &packages,
1136                        run,
1137                        verbose,
1138                        Some(&plugin_manager)
1139                    )
1140                } else {
1141                    cmd::shell::run(target_shell, scope)
1142                }
1143            }
1144            Commands::Exec {
1145                source,
1146                bin,
1147                verbose,
1148                args
1149            } => cmd::exec::run(&source, bin, &args, verbose),
1150            Commands::Download {
1151                package,
1152                archive: _,
1153                source,
1154                output_dir
1155            } => {
1156                let download_type = if source {
1157                    cmd::download::DownloadType::Source
1158                } else {
1159                    cmd::download::DownloadType::Archive
1160                };
1161                cmd::download::run(&package, download_type, output_dir)
1162            }
1163            Commands::Clean { dry_run } => cmd::clean::run(dry_run),
1164            Commands::Clone { package, location } => {
1165                cmd::clone::run(&package, location, cli.yes)
1166            }
1167            Commands::Cache { command } => match command {
1168                CacheCommands::Add { files } => cmd::cache::add(&files),
1169                CacheCommands::Clear { dry_run } => cmd::cache::clear(dry_run),
1170                CacheCommands::List => cmd::cache::list(),
1171                CacheCommands::Mirror { command } => match command {
1172                    CacheMirrorCommands::Add { url } => {
1173                        cmd::cache::add_mirror(&url)
1174                    }
1175                    CacheMirrorCommands::Remove { url } => {
1176                        cmd::cache::remove_mirror(&url)
1177                    }
1178                    CacheMirrorCommands::List => cmd::cache::list_mirrors()
1179                }
1180            },
1181            Commands::Transaction { command } => match command {
1182                TransactionCommands::List => cmd::transaction::list(),
1183                TransactionCommands::Show { id } => cmd::transaction::show(&id),
1184                TransactionCommands::Files { id } => {
1185                    cmd::transaction::files(&id)
1186                }
1187            },
1188            Commands::Repo(args) => cmd::repo::run(args),
1189            Commands::Registry(args) => cmd::registry::run(args),
1190            Commands::Home(args) => cmd::home::run(args),
1191            Commands::System(args) => cmd::system::run(args, cli.yes),
1192            Commands::Telemetry { action } => {
1193                use cmd::telemetry::{TelemetryCommand, run};
1194                let cmd = match action {
1195                    TelemetryAction::Status => TelemetryCommand::Status,
1196                    TelemetryAction::Enable => TelemetryCommand::Enable,
1197                    TelemetryAction::Disable => TelemetryCommand::Disable
1198                };
1199                run(cmd)
1200            }
1201            Commands::Create { source, app_name } => cmd::create::run(
1202                cmd::create::CreateCommand { source, app_name },
1203                cli.yes,
1204                Some(&plugin_manager)
1205            ),
1206            Commands::Downgrade { package } => {
1207                cmd::downgrade::run(&package, cli.yes, Some(&plugin_manager))
1208            }
1209            Commands::Extension(args) => {
1210                cmd::extension::run(args, cli.yes, Some(&plugin_manager))
1211            }
1212            Commands::Rollback {
1213                package,
1214                last_transaction
1215            } => {
1216                if last_transaction {
1217                    cmd::rollback::run_transaction_rollback(
1218                        cli.yes,
1219                        Some(&plugin_manager)
1220                    )
1221                } else if let Some(pkg) = package {
1222                    cmd::rollback::run(&pkg, cli.yes, Some(&plugin_manager))
1223                } else {
1224                    Ok(())
1225                }
1226            }
1227            Commands::Man {
1228                package_name,
1229                upstream,
1230                raw,
1231                no_tui
1232            } => cmd::man::run(&package_name, upstream, raw, no_tui),
1233            Commands::Package(args) => cmd::package::run(args),
1234            Commands::Pgp(args) => cmd::pgp::run(args),
1235            Commands::Helper(args) => cmd::helper::run(args),
1236            Commands::Doctor => cmd::doctor::run(),
1237            Commands::Audit {
1238                all,
1239                registry,
1240                repo
1241            } => cmd::audit::run(all, registry, repo.as_deref()),
1242            Commands::External(args) => {
1243                let (cmd_name, cmd_args) =
1244                    if let Some((first, rest)) = args.split_first() {
1245                        (first, rest.to_vec())
1246                    } else {
1247                        return Err(anyhow::anyhow!("No command specified"));
1248                    };
1249
1250                match plugin_manager.run_command(cmd_name, cmd_args) {
1251                    Ok(true) => Ok(()),
1252                    Ok(false) => {
1253                        let mut shadow_cmd = Cli::command().styles(styles);
1254                        shadow_cmd =
1255                            shadow_cmd.allow_external_subcommands(false);
1256
1257                        let err = shadow_cmd
1258                            .clone()
1259                            .try_get_matches_from(std::env::args())
1260                            .err()
1261                            .unwrap_or_else(|| {
1262                                shadow_cmd.error(
1263                                    clap::error::ErrorKind::InvalidSubcommand,
1264                                    format!(
1265                                        "unrecognized subcommand '{cmd_name}'"
1266                                    )
1267                                )
1268                            });
1269
1270                        let plugin_cmds = plugin_manager.list_commands()?;
1271                        if !plugin_cmds.is_empty() {
1272                            eprintln!(
1273                                "{}:",
1274                                "Available Plugin Commands".cyan().bold()
1275                            );
1276                            for (pcmd, pdesc) in plugin_cmds {
1277                                if pdesc.is_empty() {
1278                                    eprintln!("  {pcmd}");
1279                                } else {
1280                                    eprintln!(
1281                                        "  {:<12} {}",
1282                                        pcmd,
1283                                        pdesc.dimmed()
1284                                    );
1285                                }
1286                            }
1287                            eprintln!();
1288                        }
1289
1290                        err.exit();
1291                    }
1292                    Err(e) => Err(e)
1293                }
1294            }
1295        };
1296
1297        if let Err(e) = result {
1298            eprintln!("Error: {e}");
1299            std::process::exit(1);
1300        }
1301    }
1302    Ok(())
1303}