completers/sbx_completer/sbx_completer.ps1
|
<# .SYNOPSIS Registers Docker Sandboxes (`sbx`) tab-completion through the installed `sbx` executable. .DESCRIPTION This script registers importer-safe native completers for `sbx` and `sbx.exe`. Each Tab speaks cobra's completion protocol directly: the typed command line is projected to literal arguments without evaluating anything, `sbx __complete` is started as a child process, and its answer becomes the completion list, so subcommands, flags and sandbox names always match the installed `sbx` version. Run once per session, or dot-source it from your PowerShell profile. #> Set-StrictMode -Version Latest function Get-SbxCommandPath { $cachedPath = Get-Variable -Name SbxCommandPath -Scope Script -ErrorAction Ignore if ($null -ne $cachedPath) { return $cachedPath.Value } $sbxCommand = Get-Command -Name 'sbx', 'sbx.exe' -CommandType Application -ErrorAction Ignore | Select-Object -First 1 $script:SbxCommandPath = if ($null -ne $sbxCommand) { $sbxCommand.Source } else { $null } $script:SbxCommandPath } function ConvertTo-SbxArgument { # Renders a value as one PowerShell argument: bare when safe and no quote was typed, # otherwise in the typed quote style (single by default). A leading '@' or '#' would # start a splat or a comment. param( [string]$Value, [string]$Quote ) if (-not $Quote) { if ($Value -notmatch '[\s{}();,|&<>''"`$\u2018-\u201E]' -and $Value -notmatch '^[@#]') { return $Value } $Quote = "'" } if ($Quote -eq "'") { return "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'" } '"' + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + '"' } function Get-SbxCompletionRequest { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # Project the typed line to literal argv without evaluating anything the user typed: # string constants by value, every other element (sub-expressions, variables, splats) # by its raw text. Elements at or after the cursor are dropped, as cobra expects. $arguments = [System.Collections.Generic.List[string]]::new() $current = $null foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) { if ($element.Extent.StartOffset -ge $CursorPosition) { break } if ($element.Extent.EndOffset -ge $CursorPosition) { $current = $element break } if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $arguments.Add($element.Value) } else { $arguments.Add($element.Extent.Text) } } $quote = '' if ($null -eq $current) { $word = '' } elseif ($current -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $current.Extent.EndOffset -eq $CursorPosition) { $word = $current.Value $quote = switch ($current.StringConstantType) { 'BareWord' { '' } { $_ -in 'SingleQuoted', 'SingleQuotedHereString' } { "'" } default { '"' } } } else { $word = $current.Extent.Text.Substring(0, $CursorPosition - $current.Extent.StartOffset) } # An attached '--flag=value' completes the value; cobra answers with bare values. $prefix = '' if ($word -cmatch '^(--[^=]+=)(.*)$') { $prefix = $Matches[1] $word = $Matches[2] } if (-not $quote -and $word.Length -gt 0 -and $word[0] -in "'", '"') { $quote = [string]$word[0] $word = $word.Substring(1) if ($word.EndsWith($quote)) { $word = $word.Substring(0, $word.Length - 1) } } # A cursor after whitespace still sends the empty word being completed. $arguments.Add($prefix + $word) [pscustomobject]@{ Arguments = $arguments.ToArray() Prefix = $prefix Quote = $quote Word = $word } } function Invoke-SbxCompleteCommand { param([string[]]$Arguments) # A completion callback must stay silent: diagnostics go to the verbose stream only. $sbxCommandPath = Get-SbxCommandPath if ([string]::IsNullOrWhiteSpace($sbxCommandPath)) { Write-Verbose 'Docker Sandboxes CLI (sbx) was not found in PATH.' return $null } try { # ArgumentList hands each argument to sbx verbatim: no shell, no re-parsing. $startInfo = [System.Diagnostics.ProcessStartInfo]::new($sbxCommandPath) $startInfo.ArgumentList.Add('__complete') foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $startInfo.Environment['SBX_ACTIVE_HELP'] = '0' # A child inherits the process start directory, which Set-Location never updates. $location = Get-Location -PSProvider FileSystem -ErrorAction Ignore if ($null -ne $location) { $startInfo.WorkingDirectory = $location.ProviderPath } $process = [System.Diagnostics.Process]::Start($startInfo) try { $process.StandardInput.Close() $outputTask = $process.StandardOutput.ReadToEndAsync() [void]$process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(5000)) { Write-Verbose 'sbx __complete timed out.' $process.Kill($true) return $null } $outputTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } finally { $process.Dispose() } } catch { Write-Verbose ("sbx __complete failed: {0}" -f $_.Exception.Message) $null } } function Get-SbxCobraCompletion { param([psobject]$Request) $output = Invoke-SbxCompleteCommand -Arguments $Request.Arguments if ([string]::IsNullOrWhiteSpace($output)) { return } $lines = @($output -split '\r?\n' | Where-Object { $_ -ne '' }) $directive = 0 if ($lines.Count -eq 0 -or $lines[-1] -notmatch '^:(\d+)$' -or -not [int]::TryParse($Matches[1], [ref]$directive)) { return } # Error (1) means no completions; FilterFileExt (8) and FilterDirs (16) ask the shell for # paths. Returning nothing leaves those to PowerShell's own path completion, as does an # empty answer under the default directive. An empty answer under NoFileComp (4) cannot # suppress that fallback: PowerShell rejects an empty completion text. if (($directive -band (1 -bor 8 -bor 16)) -ne 0) { return } $candidates = foreach ($line in @($lines | Select-Object -First ($lines.Count - 1))) { $value, $description = $line.Split("`t", 2) if ($value.Length -gt 0 -and $value.StartsWith($Request.Word, [System.StringComparison]::OrdinalIgnoreCase)) { [pscustomobject]@{ Value = $value Description = if ($description) { $description } else { $value } } } } # KeepOrder (32) preserves sbx's ordering; otherwise sort like cobra's own scripts. if (($directive -band 32) -eq 0) { $candidates = $candidates | Sort-Object -Property Value } foreach ($candidate in $candidates) { $completionText = $Request.Prefix + (ConvertTo-SbxArgument -Value $candidate.Value -Quote $Request.Quote) [System.Management.Automation.CompletionResult]::new($completionText, $candidate.Value, 'ParameterValue', $candidate.Description) } } function Get-SbxFlagValueSlot { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # Closed value sets that 'sbx <command> --help' documents but cobra does not register. # --skills and --pull exist only for local sandboxes, so they are dropped under --cloud. $valueSets = @{ 'run --on-timeout' = 'stop', 'restart', 'delete' 'run --platform' = 'linux/amd64', 'linux/arm64' 'run --skills' = 'off', 'readonly', 'readwrite' 'run --pull' = 'always', 'missing', 'never' 'create --on-timeout' = 'stop', 'restart', 'delete' 'create --platform' = 'linux/amd64', 'linux/arm64' 'create --skills' = 'off', 'readonly', 'readwrite' 'create --pull' = 'always', 'missing', 'never' 'move --to' = 'local', 'cloud' 'move --on-timeout' = 'stop', 'delete' } $localOnlyFlags = '--skills', '--pull' $elements = @($CommandAst.CommandElements | Select-Object -Skip 1) $preceding = @($elements | Where-Object { $_.Extent.EndOffset -lt $CursorPosition } | ForEach-Object { $_.Extent.Text }) $current = $elements | Where-Object { $_.Extent.StartOffset -lt $CursorPosition -and $_.Extent.EndOffset -ge $CursorPosition } | Select-Object -First 1 $typed = if ($null -ne $current) { $current.Extent.Text.Substring(0, $CursorPosition - $current.Extent.StartOffset) } else { '' } # Everything after a bare '--' belongs to the agent; root flags take no value, so the first bare word is the command. if ($preceding -ccontains '--') { return $null } $command = $preceding | Where-Object { -not $_.StartsWith('-') } | Select-Object -First 1 if ($null -eq $command) { return $null } $attached = $typed -cmatch '^(--[a-z-]+=)(.*)$' if ($attached) { $flag = $Matches[1].TrimEnd('=') $prefix = $Matches[1] $valueText = $Matches[2] } elseif ($preceding.Count -gt 0 -and -not $typed.StartsWith('-')) { $flag = $preceding[-1] $prefix = '' $valueText = $typed } else { return $null } $values = $valueSets["$command $flag"] if ($null -eq $values -or (($preceding -ccontains '--cloud') -and $localOnlyFlags -ccontains $flag)) { return $null } $quote = if ($valueText.Length -gt 0 -and $valueText[0] -in "'", '"') { [string]$valueText[0] } else { '' } [pscustomobject]@{ Attached = $attached Prefix = $prefix Quote = $quote Value = $valueText.Substring($quote.Length) Values = $values } } function Get-SbxFlagValueCompletion { param([psobject]$Slot) foreach ($value in $Slot.Values) { if ($value.StartsWith($Slot.Value, [System.StringComparison]::OrdinalIgnoreCase)) { $completionText = $Slot.Prefix + (ConvertTo-SbxArgument -Value $value -Quote $Slot.Quote) [System.Management.Automation.CompletionResult]::new($completionText, $value, 'ParameterValue', $value) } } } function Invoke-SbxCompletion { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'wordToComplete', Justification = 'cobra needs the word cut from the CommandAst element at the cursor; wordToComplete spans past the cursor and drops quotes.')] param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $flagValueSlot = Get-SbxFlagValueSlot -CommandAst $commandAst -CursorPosition $cursorPosition # cobra registers no values for the table's flags, so the attached form skips the round-trip. if ($null -ne $flagValueSlot -and $flagValueSlot.Attached) { return Get-SbxFlagValueCompletion -Slot $flagValueSlot } $request = Get-SbxCompletionRequest -CommandAst $commandAst -CursorPosition $cursorPosition $results = @(Get-SbxCobraCompletion -Request $request) if ($results.Count -eq 0 -and $null -ne $flagValueSlot) { return Get-SbxFlagValueCompletion -Slot $flagValueSlot } $results } Register-ArgumentCompleter -Native -CommandName @('sbx', 'sbx.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Invoke-SbxCompletion -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |