Scoop icon indicating copy to clipboard operation
Scoop copied to clipboard

[Feature] enhance support for managing local executable shims

Open gigberg opened this issue 2 months ago • 3 comments

Feature Request

When we need to add a new command to $PATH, the scoop shim add command can be used for two main purposes:

  1. Managing local executables: It allows you to easily manage all local binaries and add them to $PATH with optional arguments. This is similar to creating a shortcut on the Windows desktop, but in this case, it’s a “command-line shortcut” to the executable—akin to ln -s /usr/bin in Linux.

For example: scoop shim add wcmd wt -p "Ubuntu"' for add windows terminal shortcut in command line

  1. Patching manifests in a scoop repository: Often, the shims defined in a manifest.json may not fully meet our needs. For example, in BusyBox, we might not want to manually edit the manifest every time it’s updated. Using scoop shim add allows us to customize shims conveniently. This provides a simple and maintainable way to manage and customize commands without touching the original manifests.

For instance: scoop shim add cdrop busybox cdrop to make a quick patch for main/busybox.json https://github.com/PowerShell/Win32-OpenSSH/issues/1652#issuecomment-1571924424

In a word, two mainly changes could be done: Feature One: scoop shim add/rm persist shims in the config file, Feature Two: scoop shim list supports the --added [pattern|optional] option to display or search only user-added shims.

Describe the solution you'd like

Tweak ./libexec/scoop-shim.ps1 logic slightly, and merge into devlope branch.

Describe alternatives you've considered

Monkey patch current scoop by the existed scoop alias function. For example:

  1. scoop alias add shim_ "tmp_command" 'tmp_summary' to generate shims/scoop-shim_.ps1 file.
  2. open and replace shims/scoop-shimnew.ps1 with below code.
  3. ①run scoop shimnew add <shim_name> <command_path> [<args>...]" to add new shim to shims/directory and config file in $ScoopDir, ②run scoop shimnew list -a [<regex_pattern>...] to show manually added shims
# Usage: scoop shim <subcommand> [<shim_name>...] [options] [other_args]
# Summary: Manipulate Scoop shims
# Help: Available subcommands: add, rm, list, info, alter.
#
# To add a custom shim, use the 'add' subcommand:
#
#     scoop shim add <shim_name> <command_path> [<args>...]
#
# To remove shims, use the 'rm' subcommand: (CAUTION: this could remove shims added by an app manifest)
#
#     scoop shim rm <shim_name> [<shim_name>...]
#
# To list all shims or matching shims, use the 'list' subcommand (`--added` to show shims added by user in config):
#
#     scoop shim list  --added [<regex_pattern>...]
#
# To show a shim's information, use the 'info' subcommand:
#
#     scoop shim info <shim_name>
#
# To alternate a shim's target source, use the 'alter' subcommand:
#
#     scoop shim alter <shim_name>
#
# Options:
#   -g, --global       Manipulate global shim(s)
#
# HINT: The FIRST double-hyphen '--', if any, will be treated as the POSIX-style command option terminator
# and will NOT be included in arguments, so if you want to pass arguments like '-g' or '--global' to
# the shim, put them after a '--'. Note that in PowerShell, you must use a QUOTED '--', e.g.,
#
#     scoop shim add myapp 'D:\path\myapp.exe' '--' myapp_args --global

# Main updated features:
# 1) `scoop shim add/rm` persist shims in the config file,
# 2) `scoop shim list` supports the --added [pattern|optional] option to display or search only user-added shims.

param($SubCommand)

. "$PSScriptRoot\..\apps\scoop\current\lib\getopt.ps1"
. "$PSScriptRoot\..\apps\scoop\current\lib\core.ps1" # for config related ops
. "$PSScriptRoot\..\apps\scoop\current\lib\install.ps1" # for rm_shim
. "$PSScriptRoot\..\apps\scoop\current\lib\system.ps1" # 'Add-Path' (indirectly)


# Read the configuration of manually added shims
function Get-AddedShimsConfig {
    $added = get_config 'added'
    if ($null -eq $added) {
        return [PSCustomObject]@{}
    }
    return $added
}

# Add shim to config
function Add-ShimToConfig($shimName, $global, $commandPath, $commandArgs) {
    $added = Get-AddedShimsConfig

    # Use new structure: shimName as key, global as a boolean field
    $shimInfo = [PSCustomObject]@{
        CommandPath = $commandPath
        CommandArgs = $commandArgs -join ' '
        AddedDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
        global = $global
    }

    # If shimName does not exist, add it
    if (-not $added.PSObject.Properties[$shimName]) {
        $added | Add-Member -MemberType NoteProperty -Name $shimName -Value $shimInfo
    } else {
        $added.$shimName = $shimInfo
    }

    set_config 'added' $added | Out-Null
}

# Remove shim from config
function Remove-ShimFromConfig($shimName, $global) {
    $added = Get-AddedShimsConfig

    # Check if shim exists and matches global flag
    if ($added.PSObject.Properties[$shimName] -and $added.$shimName.global -eq $global) {
        $added.PSObject.Properties.Remove($shimName)
        set_config 'added' $added | Out-Null
    }
}

# Test if shim is manually added
function Test-ShimAdded($shimName, $gqlobal) {
    $added = Get-AddedShimsConfig

    # Check if shim exists and matches global flag
    return ($added.PSObject.Properties[$shimName] -and $added.$shimName.global -eq $global)
}

if ($SubCommand -notin @('add', 'rm', 'list', 'info', 'alter')) {
    if (!$SubCommand) {
        error '<subcommand> missing'
    } else {
        error "'$SubCommand' is not one of available subcommands: add, rm, list, info, alter"
    }
    my_usage
    exit 1
}

# Update getopt parsing to support --added option
$opt, $other, $err = getopt $Args 'ga' 'global', 'added'
if ($err) { "scoop shim: $err"; exit 1 }

$global = $opt.g -or $opt.global
$showOnlyAdded = $opt.a -or $opt.added

if ($SubCommand -ne 'list' -and $other.Length -eq 0) {
    error "<shim_name> must be specified for subcommand '$SubCommand'"
    my_usages
    exit 1
}

if (-not (Get-FormatData ScoopShims)) {
    Update-FormatData "$PSScriptRoot\..\apps\scoop\current\supporting\formats\ScoopTypes.Format.ps1xml"
}

$localShimDir = shimdir $false
$globalShimDir = shimdir $true

function Get-ShimInfo($ShimPath) {
    $info = [Ordered]@{}
    $info.Name = strip_ext (fname $ShimPath)
    $info.Path = $ShimPath -replace 'shim$', 'exe'
    $info.Source = (get_app_name_from_shim $ShimPath) -replace '^$', 'External'
    $info.Type = if ($ShimPath.EndsWith('.ps1')) { 'ExternalScript' } else { 'Application' }
    $altShims = Get-Item -Path "$ShimPath.*" -Exclude '*.shim', '*.cmd', '*.ps1'
    if ($altShims) {
        $info.Alternatives = (@($info.Source) + ($altShims | ForEach-Object { $_.Extension.Remove(0, 1) } | Select-Object -Unique)) -join ' '
    }
    $info.IsGlobal = $ShimPath.StartsWith("$globalShimDir")
    $info.IsHidden = !((Get-Command -Name $info.Name).Path -eq $info.Path)
    # Add "Added" field
    $info.IsAdded = Test-ShimAdded $info.Name $info.IsGlobal
    [PSCustomObject]$info
}

function Get-ShimPath($ShimName, $Global) {
    '.shim', '.ps1' | ForEach-Object {
        $shimPath = Join-Path (shimdir $Global) "$ShimName$_"
        if (Test-Path -LiteralPath $shimPath) {
            return $shimPath
        }
    }
}

switch ($SubCommand) {
    'add' {
        if ($other.Length -lt 2 -or $other[1] -eq '') {
            error "<command_path> must be specified for subcommand 'add'"
            my_usage
            exit 1
            }
        $shimName = $other[0]
        $commandPath = $other[1]
        if ($other.Length -gt 2) {
            $commandArgs = $other[2..($other.Length - 1)]
        }
        if ($commandPath -notmatch '[\\/]') {
            $shortPath = $commandPath
            $commandPath = Get-ShimTarget (Get-ShimPath $shortPath $global)
            if (!$commandPath) {
                $exCommand = Get-Command $shortPath -ErrorAction SilentlyContinue
                if ($exCommand -and $exCommand.CommandType -eq 'Application') {
                    $commandPath = $exCommand.Path
                }
            }
        }
        if ($commandPath -and (Test-Path $commandPath)) {
            Write-Host "Adding $(if ($global) { 'global' } else { 'local' }) shim " -NoNewline
            Write-Host $shimName -ForegroundColor Cyan -NoNewline
            Write-Host '...'
            shim $commandPath $global $shimName $commandArgs
            # Save shim to config
            Add-ShimToConfig $shimName $global $commandPath $commandArgs
        } else {
            Write-Host "ERROR: Command path does not exist: " -ForegroundColor Red -NoNewline
            Write-Host $($other[1]) -ForegroundColor Cyan
            exit 3
        }
    }
    'rm' {
        $failed = @()
        $other | ForEach-Object {
            if (Get-ShimPath $_ $global) {
                rm_shim $_ (shimdir $global)
                # Remove shim from config
                Remove-ShimFromConfig $_ $global
            } else {
                $failed += $_
            }
        }
        if ($failed) {
            $failed | ForEach-Object {
                Write-Host "ERROR: $(if ($global) { 'Global' } else {'Local' }) shim not found: " -ForegroundColor Red -NoNewline
                Write-Host $_ -ForegroundColor Cyan
            }
            exit 3
        }
    }
    'list' {
        # Handle pattern matching
        $patterns = @($other) -ne '*'

        # Validate regex patterns
        $patterns | ForEach-Object {
            try {
                $pattern = $_
                [void][Regex]::New($pattern)
            } catch {
                Write-Host "ERROR: Invalid pattern: " -ForegroundColor Red -NoNewline
                Write-Host $pattern -ForegroundColor Magenta
                exit 1
            }
        }

        $pattern = $patterns -join '|'
        $shimInfos = @()

        if ($showOnlyAdded) {
            # When --added option is used, read from config directly
            $added = Get-AddedShimsConfig

            # Iterate over each shim in config
            $added.PSObject.Properties | ForEach-Object {
                $shimName = $_.Name
                $shimConfig = $_.Value

                # Apply regex filter
                if (!$pattern -or ($shimName -match $pattern)) {
                    # Determine shim path based on global flag
                    $shimPath = Get-ShimPath $shimName $shimConfig.global

                    if ($shimPath) {
                        $shimInfos += Get-ShimInfo $shimPath
                    }
                }
            }
        } else {
            # Original logic: scan file system
            $shims = @()

            if (!$global) {
                $shims += Get-ChildItem -Path $localShimDir -Recurse -Include '*.shim', '*.ps1' |
                    Where-Object { !$pattern -or ($_.BaseName -match $pattern) } |
                    Select-Object -ExpandProperty FullName
            }
            if (Test-Path $globalShimDir) {
                $shims += Get-ChildItem -Path $globalShimDir -Recurse -Include '*.shim', '*.ps1' |
                    Where-Object { !$pattern -or ($_.BaseName -match $pattern) } |
                    Select-Object -ExpandProperty FullName
            }

            $shimInfos = $shims.ForEach({ Get-ShimInfo $_ })
        }

        # $shimInfos | Add-Member -TypeName 'ScoopShims' -PassThru
        $shimInfos | Format-Table -Property * -AutoSize
    }
    'info' {
        $shimName = $other[0]
        $shimPath = Get-ShimPath $shimName $global
        if ($shimPath) {
            Get-ShimInfo $shimPath
        } else {
            Write-Host "ERROR: $(if ($global) { 'Global' } else { 'Local' }) shim not found: " -ForegroundColor Red -NoNewline
            Write-Host $shimName -ForegroundColor Cyan
            if (Get-ShimPath $shimName (!$global)) {
                Write-Host "But a $(if ($global) { 'local' } else {'global' }) shim exists, " -NoNewline
                Write-Host "run 'scoop shim info $shimName$(if (!$global) { ' --global' })' to show its info"
                exit 2
            }
            exit 3
        }
    }
    'alter' {
        $shimName = $other[0]
        $shimPath = Get-ShimPath $shimName $global
        if ($shimPath) {
            $shimInfo = Get-ShimInfo $shimPath
            if ($null -eq $shimInfo.Alternatives) {
                Write-Host 'ERROR: No alternatives of ' -ForegroundColor Red -NoNewline
                Write-Host $shimName -ForegroundColor Cyan -NoNewline
                Write-Host ' found.' -ForegroundColor Red
                exit 2
            }
            $shimInfo.Alternatives = $shimInfo.Alternatives.Split(' ')
            [System.Management.Automation.Host.ChoiceDescription[]]$altApps = 1..$shimInfo.Alternatives.Length | ForEach-Object {
                New-Object System.Management.Automation.Host.ChoiceDescription "&$($_)`b$($shimInfo.Alternatives[$_ - 1])", "Sets '$shimName' shim from $($shimInfo.Alternatives[$_ - 1])."
            }
            $selected = $Host.UI.PromptForChoice("Alternatives of '$shimName' command", "Please choose one that provides '$shimName' as default:", $altApps, 0)
            if ($selected -eq 0) {
                Write-Host 'INFO: ' -ForegroundColor Blue -NoNewline
                Write-Host $shimName -ForegroundColor Cyan -NoNewline
                Write-Host ' is already from ' -NoNewline
                Write-Host $shimInfo.Source -ForegroundColor DarkYellow -NoNewline
                Write-Host ', nothing changed.'
            } else {
                $newApp = $shimInfo.Alternatives[$selected]
                Write-Host 'Use ' -NoNewline
                Write-Host $shimName -ForegroundColor Cyan -NoNewline
                Write-Host ' from ' -NoNewline
                Write-Host $newApp -ForegroundColor DarkYellow -NoNewline
                Write-Host ' as default...' -NoNewline
                $pathNoExt = strip_ext $shimPath
                '', '.shim', '.cmd', '.ps1' | ForEach-Object {
                    $oldShimPath = "$pathNoExt$_"
                    $newShimPath = "$oldShimPath.$newApp"
                    if (Test-Path -Path $oldShimPath -PathType Leaf) {
                        Rename-Item -Path $oldShimPath -NewName "$oldShimPath.$($shimInfo.Source)" -Force
                        if (Test-Path -Path $newShimPath -PathType Leaf) {
                            Rename-Item -Path $newShimPath -NewName $oldShimPath -Force
                        }
                    }
                }
                Write-Host 'done.'
            }
        } else {
            Write-Host "ERROR: $(if ($global) { 'Global' } else { 'Local' }) shim not found: " -ForegroundColor Red -NoNewline
            Write-Host $shimName -ForegroundColor Cyan
            if (Get-ShimPath $shimName (!$global)) {
                Write-Host "But a $(if ($global) { 'local' } else {'global' }) shim exists, " -NoNewline
                Write-Host "run 'scoop shim alter $shimName$(if (!$global) { ' --global' })' to alternate its source"
                exit 2
            }
            exit
        }
    }
}

exit 0

gigberg avatar Sep 30 '25 12:09 gigberg

Why do you like to save manually added shims to config? It's in the PATH, and could be filtered by scoop shim list | Where-Object { $_.Source -eq 'External' }.

niheaven avatar Sep 30 '25 15:09 niheaven

Why do you like to save manually added shims to config? It's in the PATH, and could be filtered by scoop shim list | Where-Object { $_.Source -eq 'External' }.

In my computer, when shims folder got too large, almost 506 xxx.shim items, scoop list cost much too time to traverse all this files. So there is a practical requirement to speedup this progress by a cache mechanism. As a simple implementation, I record only the user-manually-added shims in the config file.

This approach does improve the performance of the less frequently used command scoop shim list --added, but it may also slow down other Scoop commands that frequently access the config file, since the file will grow larger over time as more shims are added.

Maybe we can use a new dedicated file, or just out these information in sqlite.db (which is not used by default). Anyway, speed/time should be take into account when people has managed large amount of 3rd-party shims.

By the way, thanks to your reminder, I realized that my main code change is essentially recording third-party shims in the config file—managing them like a collection of shortcut lists in a single configuration file. My motivation for this change is to achieve unified management and improve execution speed.

gigberg avatar Sep 30 '25 17:09 gigberg

I noticed that #5713 once introduced SQLite support but was later rejected. Then #5851 is merged. Currently, the SQLite cache is only used for bucket tables (scoop search), and the shim cache feature mentioned in scoop config -h has not been implemented. This remains a regret for us. https://github.com/ScoopInstaller/Scoop/blob/b588a06e41d920d2123ec70aee682bae14935939/libexec/scoop-config.ps1#L30-L31

Overall, should the SQLite feature be fully leveraged? For example, it could be applied not only to buckets but also to shims and old manifest versions (#5851 ). At present, however, an overreliance on SQLite risks making Scoop feel bloated, even if it does bring certain performance improvements.

gigberg avatar Oct 01 '25 06:10 gigberg