zoi/
cli.rs

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