### ----------------------------------------------------
### -- zef
### -- Licenses: Artistic-2.0
### -- Authors: Nick Logan, Tony O'Dell
### -- File: lib/Zef/CLI.rakumod
### ----------------------------------------------------
### -- Chunk 1 of 2
use v6.d;
use Zef:ver($?DISTRIBUTION.meta<version> // $?DISTRIBUTION.meta<ver>// '*'):api($?DISTRIBUTION.meta<api> // '*'):auth($?DISTRIBUTION.meta<auth> // '');
use Zef::Client:ver(Zef.^ver):api(Zef.^api):auth(Zef.^auth);
use Zef::Config:ver(Zef.^ver):api(Zef.^api):auth(Zef.^auth);
use Zef::Utils::FileSystem:ver(Zef.^ver):api(Zef.^api):auth(Zef.^auth);
use Zef::Identity:ver(Zef.^ver):api(Zef.^api):auth(Zef.^auth);
use Zef::Distribution:ver(Zef.^ver):api(Zef.^api):auth(Zef.^auth);
use Zef::Utils::URI:ver(Zef.^ver):api(Zef.^api):auth(Zef.^auth) :internals;
use nqp;

package Zef::CLI {

    =begin pod

    =title package Zef::CLI

    =subtitle The zef command line interface

    =head1 Synopsis

    =begin code :lang<sh>

        zef --help

        raku -e 'use Zef::CLI' install ./my-dist

    =end code

    =head1 Description

    Acts as a script-in-module-form (for precompilation reasons). As such the following two commands are the same:
    C<zef install Foo>, C<raku -e 'use Zef::CLI' install Foo>. See `zef --help` or `raku -I. bin/zef --help` for
    high level information on the various commands and options.

    =head1 Subroutines

    =head2 sub MAIN(:$version)

        multi sub MAIN(Bool :version($) where .so) {

    Show the version of zef being used.

    =head2 sub MAIN(:$help)

        multi sub MAIN(Bool :h(:help($)))

    Show the usage message.

    =head2 sub MAIN('fetch', ...)

        sub MAIN(
            'fetch',
            Bool :force(:$force-fetch),
            Int  :timeout(:$fetch-timeout) = %*ENV<ZEF_FETCH_TIMEOUT> // 600,
            Int  :degree(:$fetch-degree)   = %*ENV<ZEF_FETCH_DEGREE> || 5, # default different from Zef::Client,
            :$update, # Not Yet Implemented
            *@identities ($, *@)
        )

    Download and extract the best matching distribution found for each C<@identities>. Will exit 0 on success.

    If C<$force-fetch> is C<True> then a failure to fetch something won't throw an exception.

    If C<$fetch-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when fetching something before attempting to try the next matching fetching adapter.

    If C<$fetch-degree> is set zef will download up to that many distributions in parallel.

    =head2 sub MAIN('test', ...)

        sub MAIN(
            'test',
            Bool :force(:$force-test),
            Int  :timeout(:$test-timeout) = %*ENV<ZEF_TEST_TIMEOUT> || 3600,
            Int  :degree(:$test-degree) = %*ENV<ZEF_TEST_DEGREE> || 1,
            *@paths ($, *@)
        )

    Test the distributions from the provided C<@paths>. Will exit 0 on success.

    If C<$force-test> is C<True> then a test failure won't throw an exception.

    If C<$test-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when testing a distribution before aborting.

    If C<$test-degree> is set zef will test up to that many distributions in parallel.

    =head2 sub MAIN('build', ...)

        sub MAIN(
            'build',
            Bool :force(:$force-build),
            Int  :timeout(:$build-timeout) = %*ENV<ZEF_BUILD_TIMEOUT> || 3600,
            *@paths ($, *@)
        )

    Build the distributions from the provided C<@paths>. Will exit 0 on success.

    If C<$force-build> is C<True> then a test failure won't throw an exception.

    If C<$build-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when building a distribution before aborting.

    =head2 sub MAIN('install', ...)

        sub MAIN(
            'install',
            Bool :$fetch         = True,
            Bool :$build         = True,
            Bool :$test          = True,
            Bool :$depends       = True,
            Bool :$build-depends = $build,
            Bool :$test-depends  = $test,
            Bool :$force,
            Bool :$force-resolve = $force,
            Bool :$force-fetch   = $force,
            Bool :$force-extract = $force,
            Bool :$force-build   = $force,
            Bool :$force-test    = $force,
            Bool :$force-install = $force,
            Int  :$timeout,
            Int  :$fetch-timeout   = %*ENV<ZEF_FETCH_TIMEOUT>   // $timeout // 600,
            Int  :$extract-timeout = %*ENV<ZEF_EXTRACT_TIMEOUT> // $timeout // 3600,
            Int  :$build-timeout   = %*ENV<ZEF_BUILD_TIMEOUT>   // $timeout // 3600,
            Int  :$test-timeout    = %*ENV<ZEF_TEST_TIMEOUT>    // $timeout // 3600,
            Int  :$install-timeout = %*ENV<ZEF_INSTALL_TIMEOUT> // $timeout // 3600,
            Int  :$degree,
            Int  :$fetch-degree   = %*ENV<ZEF_FETCH_DEGREE> || $degree || 5, # default different from Zef::Client
            Int  :$test-degree    = %*ENV<ZEF_TEST_DEGREE>  || $degree || 1,
            Bool :$precompile-install,
            Bool :$dry,
            Bool :$upgrade,# Not Yet Implemented
            Bool :$deps-only,
            Bool :$serial,
            Bool :$contained,
            :$update,
            :$exclude,
            :to(:$install-to) = %*ENV<ZEF_INSTALL_TO> // $CONFIG<DefaultCUR>,
            *@wants ($, *@)
        )

    Fetch, extract, build, test, and install C<@wanted> distributions and their prerequisites. If C<@wanted> contains a
    value of C<-> then each line of STDIN will be added to C<@wanted>.

    If C<$fetch> is set to C<False> then the fetch phase will be skipped.

    If C<$build> is set to C<False> then the build phase will be skipped.

    If C<$test> is set to C<False> then the test phase will be skipped.

    If C<$depends> is set to C<False> then runtime dependencies will be ignored.

    If C<$build-depends> is set to C<False> then build dependencies will be ignored.

    If C<$test-depends> is set to C<False> then test dependencies will be ignored.

    If C<$force> is set to C<True> then zef will treat any failures as successes, potentially allowing progress to continue on failure.

    If C<$force-resolve> is set to C<True> then zef will treat any name resolution failures as successes, potentially allowing progress to continue on failure.

    If C<$force-fetch> is set to C<True> then zef will treat any fetch failures as successes, potentially allowing progress to continue on failure.

    If C<$force-extract> is set to C<True> then zef will treat any extract failures as successes, potentially allowing progress to continue on failure.

    If C<$force-build> is set to C<True> then zef will treat any build failures as successes, potentially allowing progress to continue on failure.

    If C<$force-test> is set to C<True> then zef will treat any test failures as successes, potentially allowing progress to continue on failure.

    If C<$force-install> is set to C<True> then zef will treat any install failures as successes, potentially allowing progress to continue on failure.

    If C<$timeout> is set to a non-zero c<Int> zef will wait for that many seconds when executing a given action for a specific distribution before aborting.

    If C<$fetch-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when fetching something before attempting to try the next matching fetching adapter.

    If C<$build-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when building a distribution before aborting.

    If C<$test-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when testing a distribution before aborting.

    If C<$install-timeout> is set to a non-zero c<Int> zef will wait for that many seconds when installing a distribution before aborting.

    If C<$fetch-degree> is set zef will download up to that many distributions in parallel.

    If C<$test-degree> is set zef will test up to that many distributions in parallel.

    If C<$precompile-install> is set to C<False> then Raku code won't be precompiled during installation.

    If C<$dry> is set to C<True> then the final step of actually installing the distribution will be skipped and considered a success.

    If C<$deps-only> is set to C<True> then only the dependencies of the requested identities will be processed.

    If C<$serial> is set to C<True> then zef will install each distribution after it passes its own tests. By default this is C<False> and means nothing will be installed unless all distributions passed their tests.

    If C<$contained> is set to C<True> (and combined with C<:$install-to>) zef will install all distributions to a location, including those that might otherwise already be installed and visible in another location (BETA)

    If C<$update> is set to C<True> it will force an update of all ecosystem / repository indexes. If C<$update> is set to a C<Str> or C<Array[Str]> it will only update the repositories that match the given names.

    If C<$exclude> If C<$exclude> is set to a C<Str> or C<Array[Str]> then any matching dependency will be ignored.

    If C<$to>/C<:$install-to> is set to a C<Str> or C<Array[Str]> then distributions will be installed to all of those C<CompUnit::Repository>. The short names C<site> C<home> C<vendor> can be used to reference their associated repository, otherwise a file path can be given to use a custom location. By default this is set to the zef-specific value C<auto> which makes zef use the C<site> repository if the user has write access and the C<home> otherwise.

    =head2 sub MAIN('uninstall', ...)

        sub MAIN(
            'uninstall',
            :from(:$uninstall-from),
            *@identities ($, *@)
        )

    Uninstall distributions matching C<@identities> from any visible repository.

    If C<$uninstall-from> is set to a C<Str> or C<Array[Str]> then the uninstall will apply to those repositories instead of all visible ones.

    =head2 sub MAIN('search', ...)

        multi sub MAIN('search', Int :$wrap = False, :$update, *@terms ($, *@))

    Does a basic substring search for any of C<@terms> and returns any matching distributions.

    If C<$wrap> is set to C<True> then the generated table won't wrap (nyi -- need to detect terminal width again), and if set to an C<Int> limit the generated table to that many characters wide.

    If C<$update> is set to C<True> it will force an update of all ecosystem / repository indexes. If C<$update> is set to a C<Str> or C<Array[Str]> it will only update the repository that matches the given names.

    =head2 sub MAIN('list', ...)

        multi sub MAIN('list', Int :$max?, :$update, Bool :i(:$installed), *@at)

    List all distributions from all known repositories.

    If C<$max> is set then only that many results will be returned from each repository.

    If C<$update> is set to C<True> it will force an update of all ecosystem / repository indexes. If C<$update> is set to a C<Str> or C<Array[Str]> it will only update the repositories that match the given names.

    If C<$i>/C<$installed> is set to C<True> then only installed distributions will be listed.

    If C<@at> is set to a C<Array[Str]> it will only list distributions from the repositories that match the given names.

    =head2 sub MAIN('depends', ...)

        multi sub MAIN(
            'depends',
            $identity,
            Bool :$depends       = True,
            Bool :$test-depends  = True,
            Bool :$build-depends = True,
        )


    List the dependencies of the given C<$identity>.

    If C<$depends> is set to C<False> then runtime dependencies will be ignored.

    If C<$build-depends> is set to C<False> then build dependencies will be ignored.

    If C<$test-depends> is set to C<False> then test dependencies will be ignored.

    =head2 sub MAIN('rdepends', ...)

        multi sub MAIN(
            'rdepends',
            $identity,
            Bool :$depends       = True,
            Bool :$test-depends  = True,
            Bool :$build-depends = True,
        )

    List the reverse dependencies of the given C<$identity>.

    If C<$depends> is set to C<False> then runtime dependencies will be ignored.

    If C<$build-depends> is set to C<False> then build dependencies will be ignored.

    If C<$test-depends> is set to C<False> then test dependencies will be ignored.

    =head2 sub MAIN('info', ...)

        multi sub MAIN('info', $identity, :$update, Int :$wrap = False)

    List detailed information about the best match for C<$identity>.

    If C<$wrap> is set to C<True> then the generated table won't wrap (nyi -- need to detect terminal width again), and if set to an C<Int> limit the generated table to that many characters wide.

    If C<$update> is set to C<True> it will force an update of all ecosystem / repository indexes. If C<$update> is set to a C<Str> or C<Array[Str]> it will only update the repositories that match the given names.

    =head2 sub MAIN('browse', ...)

        multi sub MAIN('browse', $identity, $url-type where * ~~ any(<homepage bugtracker source>), Bool :$open = True)

    Open a browser to the C<$url-type> resource of the best matching distribution for C<$identity>.

    If C<$open> is set to C<False> then a browser window won't be opened, instead just outputting the url to the terminal.

    =head2 sub MAIN('update', ...)

        multi sub MAIN('update', *@names)

    Update all ecosystem / repository indexes.

    If C<@names> is set to a C<Str> or C<Array[Str]> it will only update the repositories that match the given names.

    =head2 sub MAIN('nuke', ...)

        multi sub MAIN('nuke', Bool :$confirm, *@names ($, *@))

    Delete everything for C<@names>, where C<@names> can be a core repository name (C<site> C<home> C<vendor>), or a config directory reference (C<StoreDir> C<TempDir>).

    If C<$confirm> is set to C<False> then zef will not prompt to confirm deletion.

    =end pod


    my $verbosity = preprocess-args-verbosity-mutate(@*ARGS);
    %*ENV<ZEF_BUILDPM_DEBUG> = $verbosity >= DEBUG;
    %*ENV<HARNESS_VERBOSE> = $verbosity >= DEBUG;
    my $CONFIG    = preprocess-args-config-mutate(@*ARGS);
    my $VERSION   = BEGIN $?DISTRIBUTION.meta<version>;

    @*ARGS = eager gather for @*ARGS -> $arg {
        if $arg eq '--depsonly' {
            note 'DEPRECATED: --depsonly is deprecated, please use --deps-only instead';
            take '--deps-only'
        }
        elsif $arg eq '--prove' || $arg eq '--/prove' {
            note "DEPRECATED: $arg will be ignored: The prove plugin has been removed and $arg may issue an error in the future.";
        }
        else {
            take $arg
        }
    }

    proto MAIN(|) is export {
        # Suppress backtrace
        CATCH { default { try { ::("Rakudo::Internals").?LL-EXCEPTION } ?? .rethrow !! .message.&note; &*EXIT(1) } }
        {*}
    }

    #| Download specific distributions
    multi sub MAIN(
        'fetch',
        Bool :force(:$force-fetch),
        Int  :timeout(:$fetch-timeout) = %*ENV<ZEF_FETCH_TIMEOUT> // 600,
        Int  :degree(:$fetch-degree)   = %*ENV<ZEF_FETCH_DEGREE> || 5, # default different from Zef::Client,
        :$update,
        *@identities ($, *@)
    ) {
        my $client     = get-client(:config($CONFIG), :$force-fetch, :$update, :$fetch-timeout, :$fetch-degree);
        my @candidates = $client.find-candidates(@identities.map(*.&str2identity));
        abort "Failed to resolve any candidates. No reason to proceed" unless +@candidates;
        my @fetched    = $client.fetch(@candidates);
        my @fail       = @candidates.grep: {.as !~~ any(@fetched>>.as)}

        say "!!!> Fetch failed: {.as}{?($verbosity >= VERBOSE)??' at '~.dist.path!!''}" for @fail;

        exit +@fetched && +@fetched == +@candidates && +@fail == 0 ?? 0 !! 1;
    }

    #| Run tests
    multi sub MAIN(
        'test',
        Bool :force(:$force-test),
        Int  :timeout(:$test-timeout) = %*ENV<ZEF_TEST_TIMEOUT> || 3600,
        Int  :degree(:$test-degree) = %*ENV<ZEF_TEST_DEGREE> || 1,
        *@paths ($, *@)
    ) {
        my $client     = get-client(:config($CONFIG), :$force-test, :$test-timeout, :$test-degree);
        my @candidates = $client.link-candidates( @paths.map(*.&path2candidate) );
        abort "Failed to resolve any candidates. No reason to proceed" unless +@candidates;

        # We don't use CURS for `zef test .` yet, so add the CURFS path of the dist to the includes.
        for @candidates {
            $_.dist.metainfo<includes>.prepend($_.dist.path);
        }

        my @tested = $client.test(@candidates);
        my (:@test-pass, :@test-fail) := @tested.classify: {.test-results.grep(*.so) ?? <test-pass> !! <test-fail> }

        say "!!!> Testing failed: {.as}{?($verbosity >= VERBOSE)??' at '~.dist.path!!''}" for @test-fail;

        exit ?@test-fail ?? 1 !! ?@test-pass ?? 0 !! 255;
    }

    #| Run the build process
    multi sub MAIN(
        'build',
        Bool :force(:$force-build),
        Int  :timeout(:$build-timeout) = %*ENV<ZEF_BUILD_TIMEOUT> || 3600,
        *@paths ($, *@)
    ) {
        my $client = get-client(:config($CONFIG), :$force-build, |(:$build-timeout with $build-timeout),);
        my @candidates = $client.link-candidates( @paths.map(*.&path2candidate) );
        abort "Failed to resolve any candidates. No reason to proceed" unless +@candidates;

        my @built = $client.build(@candidates);
        my (:@pass, :@fail) := @built.classify: {.?build-results.grep(*.so).elems ?? <pass> !! <fail> }

        say "!!!> Build failure: {.as}{?($verbosity >= VERBOSE)??' at '~.dist.path!!''}" for @fail;

        exit ?@fail ?? 1 !! ?@pass ?? 0 !! 255;
    }

    #| Fetch, extract, build, test and install distributions
    multi sub MAIN(
        'install',
        Bool :$fetch         = True,
        Bool :$build         = True,
        Bool :$test          = True,
        Bool :$depends       = True,
        Bool :$build-depends = $build,
        Bool :$test-depends  = $test,
        Bool :$force,
        Bool :$force-resolve = $force,
        Bool :$force-fetch   = $force,
        Bool :$force-extract = $force,
        Bool :$force-build   = $force,
        Bool :$force-test    = $force,
        Bool :$force-install = $force,
        Int  :$timeout,
        Int  :$fetch-timeout   = %*ENV<ZEF_FETCH_TIMEOUT>   // $timeout // 600,
        Int  :$extract-timeout = %*ENV<ZEF_EXTRACT_TIMEOUT> // $timeout // 3600,
        Int  :$build-timeout   = %*ENV<ZEF_BUILD_TIMEOUT>   // $timeout // 3600,
        Int  :$test-timeout    = %*ENV<ZEF_TEST_TIMEOUT>    // $timeout // 3600,
        Int  :$install-timeout = %*ENV<ZEF_INSTALL_TIMEOUT> // $timeout // 3600,
        Int  :$degree,
        Int  :$fetch-degree   = %*ENV<ZEF_FETCH_DEGREE> || $degree || 5, # default different from Zef::Client
        Int  :$test-degree    = %*ENV<ZEF_TEST_DEGREE>  || $degree || 1,
        Bool :$precompile-install = True,
        Bool :$dry,
        Bool :$upgrade,
        Bool :$deps-only,
        Bool :$serial,
        Bool :$contained,
        :$update,
        :$exclude,
        :to(:$install-to) = %*ENV<ZEF_INSTALL_TO> // $CONFIG<DefaultCUR>,
        *@wants ($, *@)
    ) {
        @wants = @wants.map({ $_ eq '-' ?? $*IN.lines.Slip !! $_ }).unique;

        @wants .= map: *.&str2identity;
        my (:@paths, :@uris, :@identities) := @wants.classify: -> $wanted {
            $wanted ~~ /^[\. | \/]/                               ?? <paths>
                !! ($wanted ~~ /^\w+:/ && $wanted.IO.is-absolute) ?? <paths>      # for the rare e.g. C:\foo type paths
                !! ?Zef::Identity.new($wanted)                    ?? <identities>
                !! (my $uri = uri($wanted) and !$uri.is-relative) ?? <uris>
                !! abort("Don't understand identity: {$wanted}");
        }

        my $client = get-client(
            :config($CONFIG), :$update,          :exclude($exclude.grep(*.defined).map({ Zef::Distribution::DependencySpecification.new($_) })),
            :$depends,        :$test-depends,    :$build-depends,
            :$force-resolve,  :$force-fetch,     :$force-extract,
            :$force-build,    :$force-test,      :$force-install,
            :$fetch-timeout,  :$extract-timeout, :$build-timeout,
            :$test-timeout,   :$install-timeout, :$fetch-degree,
            :$test-degree,    :$precompile-install,
        );

        my CompUnit::Repository @at = $install-to.map(*.&str2cur);

        # LOCAL PATHS
        abort "The following were recognized as file paths but don't exist as such - {@paths.grep(!*.IO.e)}"
            if +@paths.grep(!*.IO.e);
        abort "The following were recognized as directory paths but don't contain a META6.json file - {@paths.grep(*.IO.d).grep(!*.IO.add('META6.json').e)}"
            if +@paths.grep(*.IO.d).grep(!*.IO.add('META6.json').e);
        my (:@paths-to-files, :@paths-to-dirs) := @paths.classify: { $_.IO.d ?? <paths-to-dirs> !! <paths-to-files> }
        my @dir-candidates = @paths-to-dirs.map({ Candidate.new(:as($_), :uri($_), :dist(Zef::Distribution::Local.new($_))) }) if @paths-to-dirs;
        my @file-candidates = $client.fetch( @paths-to-files.map({ Candidate.new(:as($_), :uri($_)) }) ) if +@paths-to-files;
        my @path-candidates-to-check = |@dir-candidates, |@file-candidates;
        my (:@wanted-path-candidates, :@skip-path-candidates) := @path-candidates-to-check\
            .classify: {$client.is-installed($_.dist.identity, :@at) ?? <skip-path-candidates> !! <wanted-path-candidates>}
        say "The following local path candidates are already installed: {@skip-path-candidates.map(*.uri).join(', ')}"\
            if ($verbosity >= VERBOSE) && +@skip-path-candidates;
        my @path-candidates = ?$force-install ?? @path-candidates-to-check !! @wanted-path-candidates;


        # URIS
        my @uri-candidates-to-check = $client.fetch( @uris.map({ Candidate.new(:as($_), :uri($_)) }) ) if +@uris;
        abort "No candidates found matching uri: {@uri-candidates-to-check.join(', ')}" if +@uris && +@uri-candidates-to-check == 0;
        my (:@wanted-uris, :@skip-uris) := @uri-candidates-to-check\
            .classify: {$client.is-installed($_.dist.identity, :@at) ?? <skip-uris> !! <wanted-uris>}
        say "The following uri candidates are already installed: {@skip-uris.map(*.as).join(', ')}"\
            if ($verbosity >= VERBOSE) && +@skip-uris;
        my @requested-uris = (?$force-install ?? @uri-candidates-to-check !! @wanted-uris)\
            .grep: { $_ ~~ none(@path-candidates.map(*.dist.identity)) }
        my @uri-candidates = @requested-uris;


        # IDENTITIES
        my (:@wanted-identities, :@skip-identities) := @identities\
            .classify: {$client.is-installed($_, :@at) ?? <skip-identities> !! <wanted-identities>}
        say "The following candidates are already installed: {@skip-identities.join(', ')}"\
            if ($verbosity >= VERBOSE) && +@skip-identities;
        my @requested-identities = (?$force-install ?? @identities !! @wanted-identities)\
            .grep: { $_ ~~ none(@uri-candidates.map(*.dist.identity)) }
        my @requested  = $client.find-candidates(:$upgrade, @requested-identities) if +@requested-identities;
        abort "No candidates found matching identity: {@requested-identities.join(', ')}"\
            if +@requested-identities && +@requested == 0;


        my @prereqs    = $client.find-prereq-candidates(:skip-installed(not $contained), |@path-candidates, |@uri-candidates, |@requested)\
            if +@path-candidates || +@uri-candidates || +@requested;
        my @candidates = grep *.defined, ?$deps-only
            ?? @prereqs !! (|@path-candidates, |@uri-candidates, |@requested, |@prereqs);

        unless +@candidates {
            note("All candidates are currently installed");
            exit(0) if $deps-only;
            abort("No reason to proceed. Use --force-install to continue anyway", 0) unless $force-install;
        }

        my (:@local, :@remote) := @candidates.classify: {.dist ~~ Zef::Distribution::Local ?? <local> !! <remote>}
        my @fetched = grep *.so, |@local, ($client.fetch(@remote).Slip if +@remote && $fetch);

        my CompUnit::Repository @to = $install-to.map(*.&str2cur);
        my @installed  = $client.make-install( :@to, :$fetch, :$test, :$build, :$upgrade, :$update, :$dry, :$serial, @fetched );
        my @fail       = @candidates.grep: {.as !~~ any(@installed>>.as)}

        say "!!!> Install failures: {@fail.map(*.dist.identity).join(', ')}" if +@fail;
        exit +@installed && +@installed == +@candidates && +@fail == 0 ?? 0 !! 1;
    }

    #| Uninstall distributions
    multi sub MAIN(
        'uninstall',
        :from(:$uninstall-from),
        *@identities ($, *@)
    ) {
        my $client = get-client(:config($CONFIG));
        my CompUnit::Repository @from = $uninstall-from.grep(*.defined).map(*.&str2cur);

        my @uninstalled = $client.uninstall( :@from, @identities.map(*.&str2identity) );
        my @fail        = @identities.grep(* !~~ any(@uninstalled.map(*.as)));
        if +@uninstalled == 0 && +@fail {
            note("!!!> Found no matching candidates to uninstall");
            exit 1;
        }

        for @uninstalled.classify(*.from).kv -> $from, $candidates {
            say "===> Uninstalled from $from";
            say "$_" for |$candidates>>.dist>>.identity;
        }

        say "!!!> Failed to uninstall distributions: {@fail.join('. ')}" if +@fail;
        exit +@fail ?? 1 !! 0;
    }

    #| Get a list of possible distribution candidates for the given terms
    multi sub MAIN('search', Int :$wrap = False, :$update, *@terms ($, *@)) {
        my $client = get-client(:config($CONFIG), :$update);
        my @results = $client.search(@terms).unique(:as({ .from ~ ' ' ~ .dist.identity }));

        say "===> Found " ~ +@results ~ " results";

        my @rows = eager gather for @results -> $candi {
            FIRST { take [<ID From Package Description>] }
            take [ $++, $candi.from, $candi.dist.identity, ($candi.dist.meta<description> // '') ];
        }
        print-table(@rows, :$wrap);

        exit 0;
    }

    #| A list of available modules from enabled repositories
    multi sub MAIN('list', Int :$max, :$update, Bool :i(:$installed), *@at) {
        my $client = get-client(:config($CONFIG), :$update);

        my $found := ?$installed
            ?? $client.list-installed(@at.map(*.&str2cur))
            !! $client.list-available(@at);

        my $range := defined($max) ?? 0..+$max !! *;
        my %locations = $found[$range].classify: -> $candi { $candi.from }
        for %locations.kv -> $from, $candis {
            note "===> Found via {$from}";
            for $candis.sort(*.dist.identity) -> $candi {
                say "{$candi.dist.identity}";
                say "#\t{$_}" for @($candi.dist.provides.keys.sort if ?($verbosity >= VERBOSE));
            }
        }

        exit 0;
    }

    #| Upgrade installed distributions (BETA)
    multi sub MAIN(
        'upgrade',
        Bool :$fetch         = True,
        Bool :$build         = True,
        Bool :$test          = True,
        Bool :$depends       = True,
        Bool :$test-depends  = $test,
        Bool :$build-depends = $build,
        Bool :$force,
        Bool :$force-resolve = $force,
        Bool :$force-fetch   = $force,
        Bool :$force-extract = $force,
        Bool :$force-build   = $force,
        Bool :$force-test    = $force,
        Bool :$force-install = $force,
        Int  :$timeout,
        Int  :$fetch-timeout   = %*ENV<ZEF_FETCH_TIMEOUT>   // $timeout // 600,
        Int  :$extract-timeout = %*ENV<ZEF_EXTRACT_TIMEOUT> // $timeout // 3600,
        Int  :$build-timeout   = %*ENV<ZEF_BUILD_TIMEOUT>   // $timeout // 3600,
        Int  :$test-timeout    = %*ENV<ZEF_TEST_TIMEOUT>    // $timeout // 3600,
        Int  :$install-timeout = %*ENV<ZEF_INSTALL_TIMEOUT> // $timeout // 3600,
        Int  :$degree,
        Int  :$fetch-degree   = %*ENV<ZEF_FETCH_DEGREE> || $degree || 5, # default different from Zef::Client,
        Int  :$test-degree    = %*ENV<ZEF_TEST_DEGREE>  || $degree || 1,
        Bool :$precompile-install = True,
        Bool :$dry,
        Bool :$update,
        Bool :$serial,
        :$exclude,
        :to(:$install-to) = %*ENV<ZEF_INSTALL_TO> // $CONFIG<DefaultCUR>,
        *@identities
    ) {
        @identities = @identities.map({ $_ eq '-' ?? $*IN.lines.Slip !! $_ }).unique;

        # XXX: This is a very inefficient prototype. Not sure how to handle an 'upgrade' when
        # multiple versions are already installed, so for now an 'upgrade' always means we
        # leave the previous version installed.

        my $client = get-client(
            :config($CONFIG), :exclude($exclude.grep(*.defined).map({ Zef::Distribution::DependencySpecification.new($_) })),
            :$depends,        :$test-depends,    :$build-depends,
            :$force-resolve,  :$force-fetch,     :$force-extract,
            :$force-build,    :$force-test,      :$force-install,
            :$fetch-timeout,  :$extract-timeout, :$build-timeout,
            :$test-timeout,   :$install-timeout, :$fetch-degree,
            :$test-degree,    :$precompile-install,
        );

        my @missing = @identities.grep: { not $client.is-installed($_) };
        abort "Can't upgrade identities that aren't installed: {@missing.join(', ')}" if +@missing;

        my @installed = $client.list-installed($install-to.map(*.&str2cur))\
            .sort(*.dist.ver).sort(*.dist.api).reverse\
            .unique(:as({"{.dist.name}:auth<{.dist.auth-matcher}>"}));
        my @requested = +@identities
            ?? $client.find-candidates(@identities.map(*.&str2identity))
            !! $client.find-candidates(@installed.map(*.dist.clone(ver => "*")).map(*.identity).unique);

        my (:@upgradable, :@current, :@unknown) := @requested.classify: -> $candi {
            my $latest-installed = @installed.grep({ .dist.name eq $candi.dist.name })\
                .sort({ .dist.auth-matcher ne $candi.dist.auth-matcher }).head;