[Feature] enhance support for managing local executable shims
Feature Request
When we need to add a new command to $PATH, the scoop shim add command can be used for two main purposes:
- 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
- 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:
scoop alias add shim_ "tmp_command" 'tmp_summary'to generateshims/scoop-shim_.ps1file.- open and replace
shims/scoop-shimnew.ps1with below code. - ①run
scoop shimnew add <shim_name> <command_path> [<args>...]"to add new shim toshims/directory andconfigfile in $ScoopDir, ②runscoop 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
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' }.
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.
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.