PowerShellBookmark.psm1
|
# PowerShellBookmark - save, recall, and run your favourite commands. # Bookmarks are stored as JSON so they are editable, portable, and syncable. $ErrorActionPreference = 'Stop' # ---------------- store (private helpers, non-dashed to stay out of the cmdlet namespace) ---------------- function storePath { if ($env:POWERSHELLBOOKMARK_PATH) { return $env:POWERSHELLBOOKMARK_PATH } Join-Path (Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'PowerShellBookmark') 'bookmarks.json' } function readStore { $path = storePath if (-not (Test-Path $path)) { return @() } try { $raw = Get-Content -Raw -Path $path if ([string]::IsNullOrWhiteSpace($raw)) { return @() } @($raw | ConvertFrom-Json) } catch { Write-Warning "Could not read bookmark store '$path': $($_.Exception.Message)" @() } } function writeStore { param([Parameter(Mandatory)]$Bookmarks) $path = storePath $dir = Split-Path -Parent $path if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } , @($Bookmarks) | ConvertTo-Json -Depth 6 | Set-Content -Path $path -Encoding UTF8 } # A named mutex serialises read-modify-write across concurrent windows, so two # PowerShell windows saving at the same time cannot clobber each other's changes. function enterStoreLock { $mutex = [System.Threading.Mutex]::new($false, 'PowerShellBookmark_' + [Math]::Abs((storePath).GetHashCode())) $held = $false try { $held = $mutex.WaitOne(5000) } catch [System.Threading.AbandonedMutexException] { $held = $true } [pscustomobject]@{ Mutex = $mutex; Held = $held } } function exitStoreLock { param($Lock) if ($Lock) { if ($Lock.Held) { try { $Lock.Mutex.ReleaseMutex() } catch { Write-Verbose 'mutex release skipped' } } $Lock.Mutex.Dispose() } } # Atomically bump a bookmark's usage counter (called after a run). function bumpUsage { param([Parameter(Mandatory)][string]$Name) $lock = enterStoreLock try { $store = @(readStore) $b = $store | Where-Object name -eq $Name | Select-Object -First 1 if ($b) { $b.useCount = [int]$b.useCount + 1; $b.lastUsed = (Get-Date).ToString('o'); writeStore $store } } finally { exitStoreLock $lock } } function makeBookmark { param($Name, $Command, $Tag, $Description) [pscustomobject]@{ name = $Name command = $Command tags = @($Tag | Where-Object { $_ }) description = $Description created = (Get-Date).ToString('o') lastUsed = $null useCount = 0 } } function frecencySort { param($Bookmarks) @($Bookmarks) | Sort-Object ` @{ Expression = { [int]$_.useCount }; Descending = $true }, ` @{ Expression = { if ($_.lastUsed) { [datetime]$_.lastUsed } else { [datetime]::MinValue } }; Descending = $true }, ` name } # Placeholders use a DOUBLE-brace syntax {{name}} so they don't clash with PowerShell's own { } (script blocks, hashtables). function placeholders { param([string]$Command) [regex]::Matches($Command, '\{\{\s*([^}]+?)\s*\}\}') | ForEach-Object { $_.Groups[1].Value.Trim() } | Select-Object -Unique } function expandPlaceholders { param([string]$Command, [hashtable]$Values) $out = $Command foreach ($k in $Values.Keys) { $out = [regex]::Replace($out, '\{\{\s*' + [regex]::Escape($k) + '\s*\}\}', [string]$Values[$k]) } $out } # Interactive picker: Out-ConsoleGridView -> fzf -> numbered menu, whichever is available. function pickBookmark { param($Bookmarks) $items = @($Bookmarks) if (-not $items) { return $null } if (Get-Command Out-ConsoleGridView -ErrorAction Ignore) { $chosen = $items | Select-Object name, command, @{ n = 'tags'; e = { $_.tags -join ',' } }, useCount | Out-ConsoleGridView -Title 'Bookmarks' -OutputMode Single if ($chosen) { return $items | Where-Object name -eq $chosen.name | Select-Object -First 1 } return $null } if (Get-Command fzf -ErrorAction Ignore) { $line = $items | ForEach-Object { '{0}`t{1}' -f $_.name, $_.command } | fzf --with-nth=1,2 --delimiter="`t" if ($line) { $n = ($line -split "`t")[0]; return $items | Where-Object name -eq $n | Select-Object -First 1 } return $null } # fallback: numbered menu for ($i = 0; $i -lt $items.Count; $i++) { Write-Host ('[{0}] {1,-18} {2}' -f ($i + 1), $items[$i].name, $items[$i].command) } $sel = Read-Host 'Select a number (Enter to cancel)' $n = 0 if ([int]::TryParse($sel, [ref]$n) -and $n -ge 1 -and $n -le $items.Count) { return $items[$n - 1] } $null } # Generic string picker (for history lines): Out-ConsoleGridView -> fzf -> numbered menu. function pickText { param([string[]]$Items, [string]$Title = 'Select') $arr = @($Items) if (-not $arr) { return $null } if (Get-Command Out-ConsoleGridView -ErrorAction Ignore) { return $arr | Out-ConsoleGridView -Title $Title -OutputMode Single } if (Get-Command fzf -ErrorAction Ignore) { return ($arr | fzf) } for ($i = 0; $i -lt $arr.Count; $i++) { Write-Host ('[{0}] {1}' -f ($i + 1), $arr[$i]) } $sel = Read-Host 'Select a number (Enter to cancel)' $n = 0 if ([int]::TryParse($sel, [ref]$n) -and $n -ge 1 -and $n -le $arr.Count) { return $arr[$n - 1] } $null } # Recent commands, most-recent first, de-duplicated, capped at $Count. # -AllSessions reads PowerShell's PERSISTENT PSReadLine history (survives window close / restart); # otherwise it uses the current session's Get-History. function getHistoryCommands { param([switch]$AllSessions, [int]$Count = 50) if ($AllSessions) { $path = (Get-PSReadLineOption).HistorySavePath if ($path -and (Test-Path $path)) { $lines = [System.Collections.ArrayList]@(Get-Content -Path $path -ErrorAction SilentlyContinue) $lines.Reverse() return @($lines | Where-Object { $_ -and $_.Trim() } | Select-Object -Unique | Select-Object -First $Count) } Write-Warning 'No persistent PSReadLine history found; using this session only.' } $h = @(Get-History | Select-Object -ExpandProperty CommandLine) [array]::Reverse($h) @($h | Where-Object { $_ } | Select-Object -Unique | Select-Object -First $Count) } # ---------------- public cmdlets ---------------- function Save-Bookmark { <# .SYNOPSIS Save a command as a named bookmark (favourite). .EXAMPLE Save-Bookmark push 'git push origin main' -Tag git .EXAMPLE git push origin main # then: Save-Bookmark -Last -Name push #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Position = 0)][string]$Name, [Parameter(Position = 1)][string]$Command, [string[]]$Tag, [string]$Description, [switch]$Last, [switch]$FromHistory, [switch]$AllSessions, [int]$Count = 50, [switch]$Force ) if ($Last) { $Command = (Get-History | Select-Object -Last 1).CommandLine if (-not $Command) { throw 'No previous command in history to bookmark.' } } elseif ($FromHistory) { $recent = getHistoryCommands -AllSessions:$AllSessions -Count $Count if (-not $recent) { throw 'No command history available.' } $Command = pickText -Items $recent -Title 'Pick a command to bookmark' if (-not $Command) { return } } if (-not $Command) { Write-Warning "Provide a command, or use -Last / -FromHistory. Example: bms push 'git push origin main'"; return } if (-not $Name) { $Name = Read-Host 'Name for this bookmark' } if (-not $Name) { throw 'A name is required.' } $lock = enterStoreLock try { $store = @(readStore) if (($store | Where-Object name -eq $Name) -and -not $Force) { throw "A bookmark named '$Name' already exists. Use -Force to overwrite." } if ($PSCmdlet.ShouldProcess($Name, 'Save bookmark')) { writeStore (@($store | Where-Object name -ne $Name) + (makeBookmark -Name $Name -Command $Command -Tag $Tag -Description $Description)) Write-Host "Saved bookmark '$Name'." -ForegroundColor Green } } finally { exitStoreLock $lock } } function Get-Bookmark { <# .SYNOPSIS List or search bookmarks (sorted by frecency - most used and most recent first). .EXAMPLE Get-Bookmark .EXAMPLE Get-Bookmark -Tag git #> [CmdletBinding()] param([Parameter(Position = 0)][string]$Name, [string[]]$Tag, [string]$Search) $store = readStore if ($Name) { $store = $store | Where-Object { $_.name -like $Name } } if ($Tag) { $store = $store | Where-Object { $t = $_.tags; @($Tag | Where-Object { $t -contains $_ }).Count -gt 0 } } if ($Search) { $store = $store | Where-Object { $_.command -like "*$Search*" -or $_.name -like "*$Search*" -or ((@($_.tags) -join ' ') -like "*$Search*") } } frecencySort $store | ForEach-Object { if ($_) { $_.PSObject.TypeNames.Insert(0, 'PowerShellBookmark.Bookmark') }; $_ } } function Invoke-Bookmark { <# .SYNOPSIS Run a bookmark by name, or pick one interactively. Fills any {{placeholders}} first. .EXAMPLE Invoke-Bookmark push .EXAMPLE bm # opens the picker .EXAMPLE Invoke-Bookmark checkout -Print # just return the resolved command, do not run #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'Running the user''s own saved command is the purpose of this cmdlet.')] [CmdletBinding()] param([Parameter(Position = 0)][string]$Name, [switch]$Print, [switch]$Copy) $store = @(readStore) if (-not $store) { Write-Warning 'No bookmarks yet. Save one with Save-Bookmark.'; return } $bm = $null if ($Name) { $bm = $store | Where-Object name -eq $Name | Select-Object -First 1 if (-not $bm) { $hits = @($store | Where-Object { $_.name -like "*$Name*" -or $_.command -like "*$Name*" }) if ($hits.Count -eq 1) { $bm = $hits[0] } elseif ($hits.Count -gt 1) { $bm = pickBookmark $hits } } } if (-not $bm) { $bm = pickBookmark (frecencySort $store) } if (-not $bm) { return } $cmd = $bm.command $ph = placeholders $cmd if ($ph) { $vals = @{} foreach ($p in $ph) { $vals[$p] = Read-Host $p } $cmd = expandPlaceholders -Command $cmd -Values $vals } bumpUsage -Name $bm.name if ($Print) { return $cmd } if ($Copy) { if (Get-Command Set-Clipboard -ErrorAction Ignore) { Set-Clipboard -Value $cmd; Write-Host "Copied to clipboard: $cmd" -ForegroundColor Green } else { Write-Warning 'Set-Clipboard is not available here; returning the command instead.'; Write-Output $cmd } return } Write-Host "> $cmd" -ForegroundColor Cyan Invoke-Expression $cmd } function Remove-Bookmark { <# .SYNOPSIS Delete a bookmark by name. .EXAMPLE Remove-Bookmark push #> [CmdletBinding(SupportsShouldProcess)] param([Parameter(Mandatory, Position = 0)][string]$Name) $lock = enterStoreLock try { $store = @(readStore) if (-not ($store | Where-Object name -eq $Name)) { Write-Warning "No bookmark named '$Name'."; return } if ($PSCmdlet.ShouldProcess($Name, 'Remove bookmark')) { writeStore (@($store | Where-Object name -ne $Name)) Write-Host "Removed '$Name'." -ForegroundColor Yellow } } finally { exitStoreLock $lock } } function Enable-BookmarkKeys { <# .SYNOPSIS Wire PSReadLine keys: a picker key, and a "save the current line" key. .DESCRIPTION Add "Import-Module PowerShellBookmark; Enable-BookmarkKeys" to your $PROFILE. .EXAMPLE Enable-BookmarkKeys .EXAMPLE Enable-BookmarkKeys -PickerKey 'Ctrl+j' -SaveKey 'Alt+b' #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Binds more than one key.')] [CmdletBinding()] param([string]$PickerKey = 'Ctrl+b', [string]$SaveKey = 'Alt+s') if (-not (Get-Module PSReadLine)) { Write-Warning 'PSReadLine is not loaded; bookmark keys are unavailable.'; return } Set-PSReadLineKeyHandler -Chord $PickerKey -BriefDescription 'BookmarkPicker' -LongDescription 'Insert a saved bookmark onto the line' -ScriptBlock { $bm = pickBookmark (frecencySort (readStore)) if ($bm) { [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() [Microsoft.PowerShell.PSConsoleReadLine]::Insert($bm.command) } } Set-PSReadLineKeyHandler -Chord $SaveKey -BriefDescription 'BookmarkSaveLine' -LongDescription 'Save the current line as a bookmark' -ScriptBlock { $line = $null; $cursor = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) if ($line) { $name = Read-Host "`nName for bookmark" if ($name) { Save-Bookmark -Name $name -Command $line -Force } [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } } Write-Host "Bookmark keys enabled: $PickerKey = picker, $SaveKey = save current line." -ForegroundColor Green } function Set-Bookmark { <# .SYNOPSIS Edit an existing bookmark's command, tags, or description. Keeps its usage stats. .EXAMPLE Set-Bookmark push -Command 'git push origin HEAD' .EXAMPLE Set-Bookmark push -Tag git,daily #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)][string]$Name, [Parameter(Position = 1)][string]$Command, [string[]]$Tag, [string]$Description ) $lock = enterStoreLock try { $store = @(readStore) $bm = $store | Where-Object name -eq $Name | Select-Object -First 1 if (-not $bm) { Write-Warning "No bookmark named '$Name'."; return } if ($PSCmdlet.ShouldProcess($Name, 'Update bookmark')) { if ($PSBoundParameters.ContainsKey('Command')) { $bm.command = $Command } if ($PSBoundParameters.ContainsKey('Tag')) { $bm.tags = @($Tag | Where-Object { $_ }) } if ($PSBoundParameters.ContainsKey('Description')) { $bm.description = $Description } writeStore $store Write-Host "Updated '$Name'." -ForegroundColor Green } } finally { exitStoreLock $lock } } function Rename-Bookmark { <# .SYNOPSIS Rename a bookmark, keeping its command, tags, and usage stats. .EXAMPLE Rename-Bookmark push push-main #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)][string]$Name, [Parameter(Mandatory, Position = 1)][string]$NewName, [switch]$Force ) $lock = enterStoreLock try { $store = @(readStore) $bm = $store | Where-Object name -eq $Name | Select-Object -First 1 if (-not $bm) { Write-Warning "No bookmark named '$Name'."; return } if (($store | Where-Object name -eq $NewName) -and -not $Force) { throw "A bookmark named '$NewName' already exists. Use -Force to overwrite it." } if ($PSCmdlet.ShouldProcess("$Name -> $NewName", 'Rename bookmark')) { $store = @($store | Where-Object name -ne $NewName) $bm.name = $NewName writeStore $store Write-Host "Renamed '$Name' to '$NewName'." -ForegroundColor Green } } finally { exitStoreLock $lock } } function Get-BookmarkStorePath { <# .SYNOPSIS Return the path to the bookmarks JSON file (so you can open it in any editor). .EXAMPLE code (Get-BookmarkStorePath) #> [CmdletBinding()] param() storePath } function Show-BookmarkStore { <# .SYNOPSIS Open the bookmarks JSON file in an editor for bulk editing (rename, retag, reorder, delete many at once). .DESCRIPTION Edits take effect on the next bookmark command - nothing to reload. Run Test-BookmarkStore afterwards to confirm the JSON is still valid. .EXAMPLE Show-BookmarkStore .EXAMPLE Show-BookmarkStore -Editor notepad #> [CmdletBinding()] param([string]$Editor) $path = storePath if (-not (Test-Path $path)) { $dir = Split-Path -Parent $path if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } Set-Content -Path $path -Value '[]' -Encoding UTF8 } $ed = $Editor if (-not $ed) { $ed = $env:EDITOR } if (-not $ed -and (Get-Command code -ErrorAction Ignore)) { $ed = 'code' } if ($ed) { & $ed $path } else { Invoke-Item $path } Write-Host "Editing $path" -ForegroundColor Cyan Write-Host 'Bulk-edit names, tags, or commands - changes apply on your next bookmark command. Run Test-BookmarkStore to validate.' -ForegroundColor DarkGray } function Test-BookmarkStore { <# .SYNOPSIS Validate the bookmarks JSON and report how many bookmarks it holds (run after a bulk edit). .EXAMPLE Test-BookmarkStore #> [CmdletBinding()] param() $path = storePath if (-not (Test-Path $path)) { Write-Host "No bookmark store yet at $path." -ForegroundColor Yellow; return $true } try { $data = @((Get-Content -Raw -Path $path | ConvertFrom-Json)) Write-Host "OK - $($data.Count) bookmark(s) in $path" -ForegroundColor Green $true } catch { Write-Warning "Invalid JSON in $path`: $($_.Exception.Message)" $false } } Set-Alias -Name bm -Value Invoke-Bookmark Set-Alias -Name bms -Value Save-Bookmark Set-Alias -Name bml -Value Get-Bookmark # Tab-complete saved bookmark names for the name-taking cmdlets (and the bm alias). $BookmarkNameCompleter = { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters # required by the completer signature foreach ($b in (readStore)) { if ($b.name -like "$wordToComplete*") { [System.Management.Automation.CompletionResult]::new($b.name, $b.name, 'ParameterValue', ('{0} ({1})' -f $b.name, $b.command)) } } } Register-ArgumentCompleter -CommandName Invoke-Bookmark, Remove-Bookmark, Set-Bookmark, Rename-Bookmark, bm -ParameterName Name -ScriptBlock $BookmarkNameCompleter Export-ModuleMember -Function Save-Bookmark, Get-Bookmark, Set-Bookmark, Rename-Bookmark, Invoke-Bookmark, Remove-Bookmark, Show-BookmarkStore, Get-BookmarkStorePath, Test-BookmarkStore, Enable-BookmarkKeys -Alias bm, bms, bml |