zoi/
cli.rs

1use crate::cmd;
2use crate::pkg::lock;
3use crate::utils;
4use clap::{
5    ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueHint,
6    builder::PossibleValue, builder::TypedValueParser, builder::styling,
7};
8use clap_complete::Shell;
9use clap_complete::generate;
10use std::io::{self};
11
12// Development, Special, Public or Production
13const BRANCH: &str = "Production";
14const STATUS: &str = "Release";
15const NUMBER: &str = "1.0.0";
16
17/// Zoi - The Universal Package Manager & Environment Setup Tool.
18///
19/// Part of the Zillowe Development Suite (ZDS), Zoi is designed to streamline
20/// your development workflow by managing tools and project environments.
21#[derive(Parser)]
22#[command(name = "zoi", author, about, long_about = None, disable_version_flag = true,
23    trailing_var_arg = true,
24    color = ColorChoice::Auto,
25)]
26pub struct Cli {
27    #[command(subcommand)]
28    command: Option<Commands>,
29
30    #[arg(
31        short = 'v',
32        long = "version",
33        help = "Print detailed version information"
34    )]
35    version_flag: bool,
36
37    #[arg(
38        short = 'y',
39        long,
40        help = "Automatically answer yes to all prompts",
41        global = true
42    )]
43    yes: bool,
44}
45
46#[derive(Clone, Debug)]
47struct PackageValueParser;
48
49impl TypedValueParser for PackageValueParser {
50    type Value = String;
51
52    fn parse_ref(
53        &self,
54        _cmd: &clap::Command,
55        _arg: Option<&clap::Arg>,
56        value: &std::ffi::OsStr,
57    ) -> Result<Self::Value, clap::Error> {
58        Ok(value.to_string_lossy().into_owned())
59    }
60
61    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue> + '_>> {
62        Some(Box::new(
63            utils::get_all_packages_for_completion()
64                .into_iter()
65                .map(|pkg| {
66                    let help = if pkg.description.is_empty() {
67                        pkg.repo
68                    } else {
69                        format!("[{}] {}", pkg.repo, pkg.description)
70                    };
71                    PossibleValue::new(Box::leak(pkg.display.into_boxed_str()) as &'static str)
72                        .help(Box::leak(help.into_boxed_str()) as &'static str)
73                }),
74        ))
75    }
76}
77
78#[derive(Clone, Debug)]
79struct PkgOrPathParser;
80
81impl TypedValueParser for PkgOrPathParser {
82    type Value = String;
83
84    fn parse_ref(
85        &self,
86        _cmd: &clap::Command,
87        _arg: Option<&clap::Arg>,
88        value: &std::ffi::OsStr,
89    ) -> Result<Self::Value, clap::Error> {
90        Ok(value.to_string_lossy().into_owned())
91    }
92
93    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue> + '_>> {
94        Some(Box::new(
95            utils::get_all_packages_for_completion()
96                .into_iter()
97                .map(|pkg| {
98                    let help = if pkg.description.is_empty() {
99                        pkg.repo
100                    } else {
101                        format!("[{}] {}", pkg.repo, pkg.description)
102                    };
103                    PossibleValue::new(Box::leak(pkg.display.into_boxed_str()) as &'static str)
104                        .help(Box::leak(help.into_boxed_str()) as &'static str)
105                }),
106        ))
107    }
108}
109
110#[derive(clap::ValueEnum, Clone, Debug, Copy)]
111pub enum SetupScope {
112    User,
113    System,
114}
115
116#[derive(clap::ValueEnum, Clone, Debug, Copy)]
117pub enum InstallScope {
118    User,
119    System,
120    Project,
121}
122
123#[derive(Subcommand)]
124enum Commands {
125    /// Generates shell completion scripts
126    #[command(hide = true)]
127    GenerateCompletions {
128        /// The shell to generate completions for
129        #[arg(value_enum)]
130        shell: Shell,
131    },
132
133    /// Generates man pages for zoi
134    #[command(hide = true)]
135    GenerateManual,
136
137    /// Prints concise version and build information
138    #[command(
139        alias = "v",
140        long_about = "Displays the version number, build status, branch, and commit hash. This is the same output provided by the -v and --version flags."
141    )]
142    Version,
143
144    /// Shows detailed application information and credits
145    #[command(
146        long_about = "Displays the full application name, description, author, license, and homepage information."
147    )]
148    About,
149
150    /// Displays detected operating system and architecture information
151    #[command(
152        long_about = "Detects and displays key system details, including the OS, CPU architecture, Linux distribution (if applicable), and available package managers."
153    )]
154    Info,
155
156    /// Checks for essential third-party command-line tools
157    #[command(
158        long_about = "Verifies that all required dependencies (like git) are installed and available in the system's PATH. This is useful for diagnostics."
159    )]
160    Check,
161
162    /// Downloads or updates the package database from the remote repository
163    #[command(
164        alias = "sy",
165        long_about = "Clones the official package database from GitLab to your local machine (~/.zoi/pkgs/db). If the database already exists, it verifies the remote URL and pulls the latest changes."
166    )]
167    Sync {
168        #[command(subcommand)]
169        command: Option<SyncCommands>,
170
171        /// Show the full git output
172        #[arg(short, long)]
173        verbose: bool,
174
175        /// Fallback to other mirrors if the default one fails
176        #[arg(long)]
177        fallback: bool,
178
179        /// Do not check for installed package managers
180        #[arg(long = "no-pm")]
181        no_package_managers: bool,
182
183        /// Do not attempt to set up shell completions after syncing
184        #[arg(long)]
185        no_shell_setup: bool,
186    },
187
188    /// Lists installed or all available packages
189    #[command(alias = "ls")]
190    List {
191        /// List all packages from the database, not just installed ones
192        #[arg(short, long)]
193        all: bool,
194        /// Filter by repository (e.g. 'main', 'extra')
195        #[arg(long)]
196        repo: Option<String>,
197        /// Filter by package type (package, app, collection, extension)
198        #[arg(short = 't', long = "type")]
199        package_type: Option<String>,
200    },
201
202    /// Shows detailed information about a package
203    Show {
204        /// The name of the package to show
205        #[arg(value_parser = PackageValueParser, hide_possible_values = true)]
206        package_name: String,
207        /// Display the raw, unformatted package file
208        #[arg(long)]
209        raw: bool,
210    },
211
212    /// Pin a package to a specific version
213    Pin {
214        /// The name of the package to pin
215        #[arg(value_parser = PackageValueParser, hide_possible_values = true)]
216        package: String,
217        /// The version to pin the package to
218        version: String,
219    },
220
221    /// Unpin a package, allowing it to be updated
222    Unpin {
223        /// The name of the package to unpin
224        #[arg(value_parser = PackageValueParser, hide_possible_values = true)]
225        package: String,
226    },
227
228    /// Updates one or more packages to their latest versions
229    #[command(alias = "up")]
230    Update {
231        /// The name(s) of the package(s) to update
232        #[arg(value_name = "PACKAGES", value_parser = PackageValueParser, hide_possible_values = true)]
233        package_names: Vec<String>,
234
235        /// Update all installed packages
236        #[arg(long, conflicts_with = "package_names")]
237        all: bool,
238    },
239
240    /// Installs one or more packages from a name, local file, URL, or git repository
241    #[command(alias = "i")]
242    Install {
243        /// Package names, local paths, or URLs to .pkg.lua files
244        #[arg(value_name = "SOURCES", value_hint = ValueHint::FilePath, value_parser = PkgOrPathParser, hide_possible_values = true)]
245        sources: Vec<String>,
246        /// Install from a git repository (e.g. 'Zillowe/Hello', 'gl:Zillowe/Hello')
247        #[arg(long, value_name = "REPO", conflicts_with = "sources")]
248        repo: Option<String>,
249        /// Force re-installation even if the package is already installed
250        #[arg(long)]
251        force: bool,
252        /// Accept all optional dependencies
253        #[arg(long)]
254        all_optional: bool,
255        /// The scope to install the package to
256        #[arg(long, value_enum, conflicts_with_all = &["local", "global"])]
257        scope: Option<InstallScope>,
258        /// Install packages to the current project (alias for --scope=project)
259        #[arg(long, conflicts_with = "global")]
260        local: bool,
261        /// Install packages globally for the current user (alias for --scope=user)
262        #[arg(long)]
263        global: bool,
264        /// Save the package to the project's zoi.yaml
265        #[arg(long)]
266        save: bool,
267    },
268
269    /// Uninstalls one or more packages previously installed by Zoi
270    #[command(
271        aliases = ["un", "rm", "remove"],
272        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."
273    )]
274    Uninstall {
275        /// One or more packages to uninstall
276        #[arg(value_name = "PACKAGES", required = true, value_parser = PackageValueParser, hide_possible_values = true)]
277        packages: Vec<String>,
278        /// The scope to uninstall the package from
279        #[arg(long, value_enum, conflicts_with_all = &["local", "global"])]
280        scope: Option<InstallScope>,
281        /// Uninstall packages from the current project (alias for --scope=project)
282        #[arg(long, conflicts_with = "global")]
283        local: bool,
284        /// Uninstall packages globally for the current user (alias for --scope=user)
285        #[arg(long)]
286        global: bool,
287    },
288
289    /// Execute a command defined in a local zoi.yaml file
290    #[command(
291        long_about = "Execute a command from zoi.yaml. If no command is specified, it will launch an interactive prompt to choose one."
292    )]
293    Run {
294        /// The alias of the command to execute
295        cmd_alias: Option<String>,
296        /// Arguments to pass to the command
297        args: Vec<String>,
298    },
299
300    /// Manage and set up project environments from a local zoi.yaml file
301    #[command(
302        long_about = "Checks for required packages and runs setup commands for a defined environment. If no environment is specified, it launches an interactive prompt."
303    )]
304    Env {
305        /// The alias of the environment to set up
306        env_alias: Option<String>,
307    },
308
309    /// Upgrades the Zoi binary to the latest version
310    #[command(
311        alias = "ug",
312        long_about = "Downloads the latest release from GitLab, verifies its checksum, and replaces the current executable."
313    )]
314    Upgrade {
315        /// Force a full download, skipping the patch-based upgrade
316        #[arg(long)]
317        force: bool,
318
319        /// Upgrade to a specific git tag
320        #[arg(long)]
321        tag: Option<String>,
322
323        /// Upgrade to the latest release of a specific branch (e.g. Prod, Pub)
324        #[arg(long)]
325        branch: Option<String>,
326    },
327
328    /// Removes packages that were installed as dependencies but are no longer needed
329    Autoremove,
330
331    /// Explains why a package is installed
332    Why {
333        /// The name of the package to inspect
334        #[arg(value_parser = PackageValueParser, hide_possible_values = true)]
335        package_name: String,
336    },
337
338    /// Find which package owns a file
339    #[command(alias = "owns")]
340    Owner {
341        /// Path to the file
342        #[arg(value_hint = ValueHint::FilePath)]
343        path: std::path::PathBuf,
344    },
345
346    /// List all files owned by a package
347    Files {
348        /// The name of the package
349        #[arg(value_parser = PackageValueParser, hide_possible_values = true)]
350        package: String,
351    },
352
353    /// Searches for packages by name or description
354    #[command(
355        alias = "s",
356        long_about = "Searches for a case-insensitive term in the name, description, and tags of all available packages in the database. Filter by repo, type, or tags."
357    )]
358    Search {
359        /// The term to search for (e.g. 'editor', 'cli')
360        search_term: String,
361        /// Filter by repository (e.g. 'main', 'extra')
362        #[arg(long)]
363        repo: Option<String>,
364        /// Filter by package type (package, app, collection, extension)
365        #[arg(long = "type")]
366        package_type: Option<String>,
367        /// Filter by tags (any match). Multiple via comma or repeated -t
368        #[arg(short = 't', long = "tag", value_delimiter = ',', num_args = 1..)]
369        tags: Option<Vec<String>>,
370    },
371
372    /// Installs completion scripts for a given shell
373    Shell {
374        /// The shell to install completions for
375        #[arg(value_enum)]
376        shell: Shell,
377    },
378
379    /// Configures the shell environment for Zoi
380    #[command(
381        long_about = "Adds the Zoi binary directory to your shell's PATH to make Zoi packages' executables available as commands."
382    )]
383    Setup {
384        /// The scope to apply the setup to (user or system-wide)
385        #[arg(long, value_enum, default_value = "user")]
386        scope: SetupScope,
387    },
388
389    /// Download and execute a binary package without installing it
390    #[command(
391        alias = "x",
392        long_about = "Downloads a binary to a temporary cache and executes it in a shell. All arguments after the package name are passed as arguments to the shell command."
393    )]
394    Exec {
395        /// Package name, local path, or URL to execute
396        #[arg(value_name = "SOURCE", value_parser = PkgOrPathParser, value_hint = ValueHint::FilePath, hide_possible_values = true)]
397        source: String,
398
399        /// Force execution from a fresh download, bypassing any cache.
400        #[arg(long)]
401        upstream: bool,
402
403        /// Force execution from the cache, failing if the package is not cached.
404        #[arg(long)]
405        cache: bool,
406
407        /// Force execution from the local project installation.
408        #[arg(long)]
409        local: bool,
410
411        /// Arguments to pass to the executed command
412        #[arg(value_name = "ARGS")]
413        args: Vec<String>,
414    },
415
416    /// Clears the cache of downloaded package binaries
417    Clean,
418
419    /// Manage package repositories
420    #[command(
421        aliases = ["repositories"],
422        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>)."
423    )]
424    Repo(cmd::repo::RepoCommand),
425
426    /// Manage telemetry settings (opt-in analytics)
427    #[command(
428        long_about = "Manage opt-in anonymous telemetry used to understand package popularity. Default is disabled."
429    )]
430    Telemetry {
431        #[arg(value_enum)]
432        action: TelemetryAction,
433    },
434
435    /// Create an application using a package template
436    Create {
437        /// Package name, @repo/name, local .pkg.lua path, or URL
438        source: String,
439        /// The application name to substitute into template commands
440        app_name: Option<String>,
441    },
442
443    /// Manage Zoi extensions
444    #[command(alias = "ext")]
445    Extension(ExtensionCommand),
446
447    /// Rollback a package to the previously installed version
448    Rollback {
449        /// The name of the package to rollback
450        #[arg(value_name = "PACKAGE", value_parser = PackageValueParser, hide_possible_values = true, required_unless_present = "last_transaction")]
451        package: Option<String>,
452
453        /// Rollback the last transaction
454        #[arg(long, conflicts_with = "package")]
455        last_transaction: bool,
456    },
457
458    /// Shows a package's manual
459    Man {
460        /// The name of the package to show the manual for
461        #[arg(value_parser = PackageValueParser, hide_possible_values = true)]
462        package_name: String,
463        /// Always look at the upstream manual even if it's downloaded
464        #[arg(long)]
465        upstream: bool,
466        /// Print the manual to the terminal raw
467        #[arg(long)]
468        raw: bool,
469    },
470
471    /// Build, create, and manage Zoi packages
472    #[command(alias = "pkg")]
473    Package(cmd::package::PackageCommand),
474
475    /// Manage PGP keys for package signature verification
476    Pgp(cmd::pgp::PgpCommand),
477
478    /// Helper commands for various tasks
479    Helper(cmd::helper::HelperCommand),
480}
481
482#[derive(clap::Parser, Debug)]
483pub struct ExtensionCommand {
484    #[command(subcommand)]
485    pub command: ExtensionCommands,
486}
487
488#[derive(clap::Subcommand, Debug)]
489pub enum ExtensionCommands {
490    /// Add an extension
491    Add {
492        /// The name of the extension to add
493        #[arg(required = true)]
494        name: String,
495    },
496    /// Remove an extension
497    Remove {
498        /// The name of the extension to remove
499        #[arg(required = true)]
500        name: String,
501    },
502}
503
504#[derive(clap::Subcommand, Clone)]
505pub enum SyncCommands {
506    /// Add a new registry
507    Add {
508        /// URL of the registry to add
509        url: String,
510    },
511    /// Remove a configured registry by its handle
512    Remove {
513        /// Handle of the registry to remove
514        handle: String,
515    },
516    /// List configured registries
517    #[command(alias = "ls")]
518    List,
519    /// Set the default registry URL
520    Set {
521        /// URL or keyword (default, github, gitlab, codeberg)
522        url: String,
523    },
524}
525
526#[derive(clap::ValueEnum, Clone)]
527enum TelemetryAction {
528    Status,
529    Enable,
530    Disable,
531}
532
533pub fn run() {
534    let styles = styling::Styles::styled()
535        .header(styling::AnsiColor::Yellow.on_default() | styling::Effects::BOLD)
536        .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
537        .literal(styling::AnsiColor::Green.on_default())
538        .placeholder(styling::AnsiColor::Cyan.on_default());
539
540    let commit: &str = option_env!("ZOI_COMMIT_HASH").unwrap_or("dev");
541    let mut cmd = Cli::command().styles(styles);
542    let matches = cmd.clone().get_matches();
543    let cli = match Cli::from_arg_matches(&matches) {
544        Ok(cli) => cli,
545        Err(err) => {
546            err.print().unwrap();
547            std::process::exit(1);
548        }
549    };
550
551    utils::check_path();
552
553    if cli.version_flag {
554        cmd::version::run(BRANCH, STATUS, NUMBER, commit);
555        return;
556    }
557
558    if let Some(command) = cli.command {
559        let needs_lock = matches!(
560            command,
561            Commands::Install { .. }
562                | Commands::Uninstall { .. }
563                | Commands::Update { .. }
564                | Commands::Autoremove
565                | Commands::Rollback { .. }
566                | Commands::Package(_)
567        );
568
569        let _lock_guard = if needs_lock {
570            match lock::acquire_lock() {
571                Ok(guard) => Some(guard),
572                Err(e) => {
573                    eprintln!("Error: {}", e);
574                    std::process::exit(1);
575                }
576            }
577        } else {
578            None
579        };
580
581        let result = match command {
582            Commands::GenerateCompletions { shell } => {
583                let mut cmd = Cli::command();
584                let bin_name = cmd.get_name().to_string();
585                generate(shell, &mut cmd, bin_name, &mut io::stdout());
586                Ok(())
587            }
588            Commands::GenerateManual => cmd::gen_man::run().map_err(Into::into),
589            Commands::Version => {
590                cmd::version::run(BRANCH, STATUS, NUMBER, commit);
591                Ok(())
592            }
593            Commands::About => {
594                cmd::about::run(BRANCH, STATUS, NUMBER, commit);
595                Ok(())
596            }
597            Commands::Info => {
598                cmd::info::run(BRANCH, STATUS, NUMBER, commit);
599                Ok(())
600            }
601            Commands::Check => {
602                cmd::check::run();
603                Ok(())
604            }
605            Commands::Sync {
606                command,
607                verbose,
608                fallback,
609                no_package_managers,
610                no_shell_setup,
611            } => {
612                if let Some(cmd) = command {
613                    match cmd {
614                        SyncCommands::Add { url } => cmd::sync::add_registry(&url),
615                        SyncCommands::Remove { handle } => cmd::sync::remove_registry(&handle),
616                        SyncCommands::List => cmd::sync::list_registries(),
617                        SyncCommands::Set { url } => cmd::sync::set_registry(&url),
618                    }
619                } else {
620                    cmd::sync::run(verbose, fallback, no_package_managers, no_shell_setup);
621                }
622                Ok(())
623            }
624            Commands::List {
625                all,
626                repo,
627                package_type,
628            } => {
629                let _ = cmd::list::run(all, repo, package_type);
630                Ok(())
631            }
632            Commands::Show { package_name, raw } => {
633                cmd::show::run(&package_name, raw);
634                Ok(())
635            }
636            Commands::Pin { package, version } => {
637                cmd::pin::run(&package, &version);
638                Ok(())
639            }
640            Commands::Unpin { package } => {
641                cmd::unpin::run(&package);
642                Ok(())
643            }
644            Commands::Update { package_names, all } => {
645                if !all && package_names.is_empty() {
646                    let mut cmd = Cli::command();
647                    if let Some(subcmd) = cmd.find_subcommand_mut("update") {
648                        subcmd.print_help().unwrap();
649                    }
650                } else {
651                    cmd::update::run(all, &package_names, cli.yes);
652                }
653                Ok(())
654            }
655            Commands::Install {
656                sources,
657                repo,
658                force,
659                all_optional,
660                scope,
661                local,
662                global,
663                save,
664            } => {
665                cmd::install::run(
666                    &sources,
667                    repo,
668                    force,
669                    all_optional,
670                    cli.yes,
671                    scope,
672                    local,
673                    global,
674                    save,
675                );
676                Ok(())
677            }
678            Commands::Uninstall {
679                packages,
680                scope,
681                local,
682                global,
683            } => {
684                cmd::uninstall::run(&packages, scope, local, global);
685                Ok(())
686            }
687            Commands::Run { cmd_alias, args } => {
688                cmd::run::run(cmd_alias, args);
689                Ok(())
690            }
691            Commands::Env { env_alias } => {
692                cmd::env::run(env_alias);
693                Ok(())
694            }
695            Commands::Upgrade { force, tag, branch } => {
696                cmd::upgrade::run(BRANCH, STATUS, NUMBER, force, tag, branch);
697                Ok(())
698            }
699            Commands::Autoremove => {
700                cmd::autoremove::run(cli.yes);
701                Ok(())
702            }
703            Commands::Why { package_name } => cmd::why::run(&package_name),
704            Commands::Owner { path } => {
705                cmd::owner::run(&path);
706                Ok(())
707            }
708            Commands::Files { package } => {
709                cmd::files::run(&package);
710                Ok(())
711            }
712            Commands::Search {
713                search_term,
714                repo,
715                package_type,
716                tags,
717            } => cmd::search::run(search_term, repo, package_type, tags),
718            Commands::Shell { shell } => {
719                cmd::shell::run(shell);
720                Ok(())
721            }
722            Commands::Setup { scope } => {
723                cmd::setup::run(scope);
724                Ok(())
725            }
726            Commands::Exec {
727                source,
728                upstream,
729                cache,
730                local,
731                args,
732            } => {
733                cmd::exec::run(source, args, upstream, cache, local);
734                Ok(())
735            }
736            Commands::Clean => {
737                cmd::clean::run();
738                Ok(())
739            }
740            Commands::Repo(args) => {
741                cmd::repo::run(args);
742                Ok(())
743            }
744            Commands::Telemetry { action } => {
745                use cmd::telemetry::{TelemetryCommand, run};
746                let cmd = match action {
747                    TelemetryAction::Status => TelemetryCommand::Status,
748                    TelemetryAction::Enable => TelemetryCommand::Enable,
749                    TelemetryAction::Disable => TelemetryCommand::Disable,
750                };
751                run(cmd);
752                Ok(())
753            }
754            Commands::Create { source, app_name } => {
755                cmd::create::run(cmd::create::CreateCommand { source, app_name }, cli.yes);
756                Ok(())
757            }
758            Commands::Extension(args) => cmd::extension::run(args, cli.yes),
759            Commands::Rollback {
760                package,
761                last_transaction,
762            } => {
763                if last_transaction {
764                    cmd::rollback::run_transaction_rollback(cli.yes)
765                } else if let Some(pkg) = package {
766                    cmd::rollback::run(&pkg, cli.yes)
767                } else {
768                    Ok(())
769                }
770            }
771            Commands::Man {
772                package_name,
773                upstream,
774                raw,
775            } => cmd::man::run(&package_name, upstream, raw),
776            Commands::Package(args) => {
777                cmd::package::run(args);
778                Ok(())
779            }
780            Commands::Pgp(args) => cmd::pgp::run(args),
781            Commands::Helper(args) => cmd::helper::run(args),
782        };
783
784        if let Err(e) = result {
785            eprintln!("Error: {}", e);
786            std::process::exit(1);
787        }
788    } else {
789        cmd.print_help().unwrap();
790    }
791}