completers/gh_cli_completer/gh_cli_completer.ps1
|
<# .SYNOPSIS Registers GitHub CLI (`gh`) tab-completion through the installed `gh` executable. .DESCRIPTION This script registers importer-safe native completers for `gh` and `gh.exe`. Every Tab speaks cobra's completion protocol to the installed GitHub CLI directly: the typed words are projected from the command AST without being evaluated and passed to `gh __complete` as a process argument list. Run once per session, or dot-source it from your PowerShell profile. #> Set-StrictMode -Version Latest function Get-GhCliCommandPath { # A missing gh is cached too, so a machine without it pays for the lookup once per session. $cachedPath = Get-Variable -Name GhCliCommandPath -Scope Script -ErrorAction Ignore if ($null -ne $cachedPath) { return $cachedPath.Value } $ghCommand = Get-Command -Name 'gh', 'gh.exe' -CommandType Application -ErrorAction Ignore | Select-Object -First 1 $script:GhCliCommandPath = if ($null -ne $ghCommand) { $ghCommand.Source } else { $null } $script:GhCliCommandPath } function Get-GhCliCompletionRequest { # Projects the typed command line to the argument list cobra expects, without evaluating anything: # string constants by value, every other prior element by its literal text, the word under the cursor # cut at the cursor, and an empty final argument when the cursor follows whitespace. Returns $null # when the word under the cursor is not literal text (a variable, subexpression, splat, ...). param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $arguments = [System.Collections.Generic.List[string]]::new() $word = $null $quote = '' foreach ($element in ($CommandAst.CommandElements | Select-Object -Skip 1)) { $start = $element.Extent.StartOffset if ($start -ge $CursorPosition) { break } if ($element.Extent.EndOffset -lt $CursorPosition) { if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $arguments.Add($element.Value) } else { $arguments.Add($element.Extent.Text) } continue } $typed = $element.Extent.Text.Substring(0, $CursorPosition - $start) $isWhole = $typed.Length -eq $element.Extent.Text.Length if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { switch ($element.StringConstantType) { 'BareWord' { $word = if ($isWhole) { $element.Value } else { $typed -replace '`(.)', '$1' } } 'SingleQuoted' { $quote = "'" $word = if ($isWhole) { $element.Value } else { $typed.Substring(1) -replace "''", "'" } } 'DoubleQuoted' { $quote = '"' $word = if ($isWhole) { $element.Value } else { $typed.Substring(1) -replace '`(.)', '$1' } } default { return $null } } } elseif ($element -is [System.Management.Automation.Language.CommandParameterAst] -and $null -eq $element.Argument) { $word = $typed } elseif ($element -is [System.Management.Automation.Language.ArrayLiteralAst] -and @($element.Elements | Where-Object { $_ -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -or $_.StringConstantType -ne 'BareWord' }).Count -eq 0) { # A bare comma list such as '--json number,ti' reaches gh as one argument, exactly as typed. $word = $typed } else { return $null } break } if ($null -eq $word) { $word = '' } $arguments.Add($word) [pscustomobject]@{ Arguments = $arguments.ToArray() Word = $word Quote = $quote } } function Invoke-GhCliCompleteRequest { # Runs 'gh __complete <arguments>' directly (no shell, no command-line string) and returns its # ANSI-stripped stdout lines. The session's location is handed over because gh resolves the # repository from its working directory. param([string[]]$Arguments) $ghCommandPath = Get-GhCliCommandPath if (-not $ghCommandPath) { return @() } try { $startInfo = [System.Diagnostics.ProcessStartInfo]::new($ghCommandPath) [void]$startInfo.ArgumentList.Add('__complete') foreach ($argument in $Arguments) { [void]$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 # ActiveHelp lines have no PowerShell rendering; set on the child only. $startInfo.Environment['GH_ACTIVE_HELP'] = '0' $location = Get-Location -PSProvider FileSystem -ErrorAction Ignore if ($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)) { try { $process.Kill($true) } catch { Write-Verbose $_.Exception.Message } return @() } @(($outputTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') -split '\r?\n' | Where-Object { $_ -ne '' }) } finally { $process.Dispose() } } catch { Write-Verbose ("gh __complete failed: {0}" -f $_.Exception.Message) @() } } function ConvertTo-GhCliArgument { # 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 bare comma list stays bare: PowerShell passes it to a # native command as one argument. 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-GhCliLiveCompletion { # Cobra's protocol: 'value<TAB>description' lines, then ':<directive>' with the bit flags # 1 Error, 2 NoSpace, 4 NoFileComp, 8 FilterFileExt, 16 FilterDirs, 32 KeepOrder, 64 ActiveHelp. # An empty answer leaves the slot to the fallback and then to PowerShell's own path completion. param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $request = Get-GhCliCompletionRequest -CommandAst $CommandAst -CursorPosition $CursorPosition if ($null -eq $request) { return @() } $lines = @(Invoke-GhCliCompleteRequest -Arguments $request.Arguments) if ($lines.Count -eq 0 -or $lines[-1] -notmatch '^:(?<directive>\d+)$') { return @() } $directive = [int]$Matches['directive'] if ($directive -band (1 + 8 + 16)) { return @() } # For '--flag=value' cobra answers with bare values; the flag spelling goes back in front. $prefix = '' $filter = $request.Word if ($filter -match '^(?<flag>--[^=]+=)(?<value>.*)$') { $prefix = $Matches['flag'] $filter = $Matches['value'] } $candidates = @( foreach ($line in ($lines | Select-Object -SkipLast 1)) { $name, $description = $line.Split("`t", 2) if ($name -and $name.StartsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { [pscustomobject]@{ Name = $prefix + $name; Description = $description } } } ) if (-not ($directive -band 32)) { $candidates = @($candidates | Sort-Object -Property Name) } foreach ($candidate in $candidates) { $resultType = if (-not $prefix -and $candidate.Name.StartsWith('-')) { 'ParameterName' } else { 'ParameterValue' } $toolTip = if ([string]::IsNullOrWhiteSpace($candidate.Description)) { $candidate.Name } else { $candidate.Description } [System.Management.Automation.CompletionResult]::new( (ConvertTo-GhCliArgument -Value $candidate.Name -Quote $request.Quote), $candidate.Name, $resultType, $toolTip) } } function Get-GhCliConfigDirectory { $candidates = @() if ($env:GH_CONFIG_DIR) { $candidates += $env:GH_CONFIG_DIR } if ($env:APPDATA) { $candidates += (Join-Path -Path $env:APPDATA -ChildPath 'GitHub CLI') } if ($HOME) { $candidates += (Join-Path -Path $HOME -ChildPath '.config\gh') } foreach ($candidate in $candidates) { if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate } } $null } function Get-GhCliConfiguredAliasList { # The 'aliases:' block of config.yml, read locally instead of spawning 'gh alias list'. $configDirectory = Get-GhCliConfigDirectory if (-not $configDirectory) { return @() } $configFile = Join-Path -Path $configDirectory -ChildPath 'config.yml' if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return @() } $names = New-Object System.Collections.Generic.List[string] $inAliases = $false foreach ($line in @(Get-Content -LiteralPath $configFile -ErrorAction Ignore)) { if ($line -match '^aliases:\s*$') { $inAliases = $true continue } if ($inAliases) { if ($line -match '^\s+(?<name>[A-Za-z0-9_-]+):') { [void]$names.Add($Matches['name']) continue } if ($line -match '^\S' -and $line -notmatch '^\s*#') { $inAliases = $false } } } @($names.ToArray()) } function Get-GhCliConfiguredHostList { $hosts = New-Object System.Collections.Generic.List[string] [void]$hosts.Add('github.com') $configDirectory = Get-GhCliConfigDirectory if ($configDirectory) { $hostsFile = Join-Path -Path $configDirectory -ChildPath 'hosts.yml' foreach ($line in @(Get-Content -LiteralPath $hostsFile -ErrorAction Ignore)) { if ($line -match '^(?<host>[A-Za-z0-9.-]+):\s*$') { [void]$hosts.Add($Matches['host']) } } } @($hosts.ToArray() | Select-Object -Unique) } function Get-GhCliHelpTopicList { $cachedTopics = Get-Variable -Name GhCliHelpTopics -Scope Script -ErrorAction Ignore if ($null -ne $cachedTopics) { return $cachedTopics.Value } $topics = New-Object System.Collections.Generic.List[object] $ghCommandPath = Get-GhCliCommandPath if ($ghCommandPath) { $inTopics = $false foreach ($line in @($null | & $ghCommandPath --help 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' })) { if ($line -match '^HELP TOPICS') { $inTopics = $true continue } if ($inTopics) { if ($line -match '^\s+(?<name>[a-z-]+):\s+(?<desc>\S.*)$') { [void]$topics.Add([pscustomobject]@{ Name = $Matches['name']; Description = $Matches['desc'].Trim() }) continue } if ($line -match '^\S') { break } } } } $script:GhCliHelpTopics = @($topics.ToArray()) $script:GhCliHelpTopics } function Get-GhCliLocalBranchList { $gitCommand = Get-Command -Name 'git' -CommandType Application -ErrorAction Ignore | Select-Object -First 1 if (-not $gitCommand) { return @() } try { @($null | & $gitCommand.Source for-each-ref --format='%(refname:short)' refs/heads refs/remotes 2>$null | Where-Object { $_ -and $_ -notmatch '/HEAD$' }) } catch { @() } } function Get-GhCliExtensionList { # Installed extensions are the gh-* entries of gh's data directory, read locally instead of # spawning 'gh extension list'. $dataDirectory = if ($env:XDG_DATA_HOME) { Join-Path -Path $env:XDG_DATA_HOME -ChildPath 'gh' } elseif ($env:LOCALAPPDATA) { Join-Path -Path $env:LOCALAPPDATA -ChildPath 'GitHub CLI' } elseif ($HOME) { Join-Path -Path $HOME -ChildPath '.local\share\gh' } if (-not $dataDirectory) { return @() } $extensionDirectory = Join-Path -Path $dataDirectory -ChildPath 'extensions' @(Get-ChildItem -LiteralPath $extensionDirectory -Filter 'gh-*' -ErrorAction Ignore | ForEach-Object { $_.Name.Substring(3) }) } function Get-GhCliWorkflowFileList { $workflowDirectory = Join-Path -Path (Get-Location).Path -ChildPath '.github\workflows' @(Get-ChildItem -LiteralPath $workflowDirectory -File -ErrorAction Ignore | Where-Object { $_.Extension -in @('.yml', '.yaml') } | ForEach-Object { $_.Name }) } function ConvertTo-GhCliValueResult { param( [string[]]$Values, [string]$CurrentWord, [string]$ToolTip, [string]$Placeholder, # Attached option spelling (--method=, -X) kept in front of every completion text. [string]$Prefix ) $typed = if ($null -eq $CurrentWord) { '' } else { $CurrentWord.Trim([char[]]@([char]34, [char]39)) } $results = @( foreach ($value in @($Values)) { if (-not [string]::IsNullOrWhiteSpace($value) -and $value.StartsWith($typed, [System.StringComparison]::OrdinalIgnoreCase)) { $completionText = if ($value -match '\s') { '"' + $value + '"' } else { $Prefix + $value } [System.Management.Automation.CompletionResult]::new($completionText, $value, 'ParameterValue', $ToolTip) } } ) if ($results.Count -gt 0) { return $results } if ($Placeholder) { $completionText = if ($typed) { $typed } else { $Placeholder } return @([System.Management.Automation.CompletionResult]::new($completionText, $Placeholder, 'ParameterValue', $ToolTip)) } @() } function Get-GhCliFallbackCompletion { # Value model for the slots gh's own completer leaves empty: closed enums from gh's help and # cheap local state (config aliases and hosts, git branches, workflow files). param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $priorTokens = @( foreach ($element in ($CommandAst.CommandElements | Select-Object -Skip 1)) { if ($element.Extent.EndOffset -lt $CursorPosition) { $element.Extent.Text.Trim([char[]]@([char]34, [char]39)) } } ) $words = @($priorTokens | Where-Object { -not $_.StartsWith('-') }) if ($words.Count -gt 0 -and $words[0] -eq 'co') { $words = @('pr', 'checkout') + @($words | Select-Object -Skip 1) } $commandPath = (@($words | Select-Object -First 2) -join ' ') $positionals = @($words | Select-Object -Skip 2) $previousToken = if ($priorTokens.Count -gt 0) { $priorTokens[-1] } else { '' } $currentWord = if ($null -eq $WordToComplete) { '' } else { $WordToComplete } if ($previousToken -eq '--hostname' -or $previousToken -eq '-h' -and $commandPath -like 'auth *') { return ConvertTo-GhCliValueResult -Values (Get-GhCliConfiguredHostList) -CurrentWord $currentWord -ToolTip 'GitHub host name.' -Placeholder '<hostname>' } if ($commandPath -like 'api*') { $methods = @('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD') $methodToolTip = 'HTTP method for the request (default GET).' if ($previousToken -in @('-X', '--method')) { return ConvertTo-GhCliValueResult -Values $methods -CurrentWord $currentWord -ToolTip $methodToolTip } # Attached spellings, both accepted by gh: --method=POST and -XPOST. if ($currentWord -cmatch '^(?<flag>--method=|-X)(?<value>.+)?$' -and ($Matches['flag'] -eq '--method=' -or $Matches['value'])) { return ConvertTo-GhCliValueResult -Values $methods -CurrentWord ([string]$Matches['value']) -ToolTip $methodToolTip -Prefix $Matches['flag'] } } switch -Regex ($commandPath) { '^api(\s|$)' { if ($positionals.Count -eq 0 -and -not $previousToken.StartsWith('-')) { return ConvertTo-GhCliValueResult -Values @('user', 'graphql', 'repos/{owner}/{repo}', 'repos/{owner}/{repo}/issues', 'repos/{owner}/{repo}/pulls', 'orgs/{org}', 'search/repositories', 'rate_limit') -CurrentWord $currentWord -ToolTip 'REST endpoint path (or "graphql").' -Placeholder '<endpoint>' } } '^config (get|set)$' { $keys = @('api_host', 'git_protocol', 'editor', 'prompt', 'prefer_editor_prompt', 'pager', 'http_unix_socket', 'browser', 'clipboard', 'color_labels', 'accessible_colors', 'accessible_prompter', 'spinner', 'telemetry') if ($positionals.Count -eq 0) { return ConvertTo-GhCliValueResult -Values $keys -CurrentWord $currentWord -ToolTip 'gh configuration key.' -Placeholder '<key>' } if ($commandPath -eq 'config set' -and $positionals.Count -eq 1) { $values = switch ($positionals[0]) { 'git_protocol' { @('https', 'ssh') } 'prompt' { @('enabled', 'disabled') } 'prefer_editor_prompt' { @('enabled', 'disabled') } 'clipboard' { @('enabled', 'disabled') } 'color_labels' { @('enabled', 'disabled') } 'accessible_colors' { @('enabled', 'disabled') } 'accessible_prompter' { @('enabled', 'disabled') } 'spinner' { @('enabled', 'disabled') } 'telemetry' { @('enabled', 'disabled', 'log') } default { @() } } return ConvertTo-GhCliValueResult -Values $values -CurrentWord $currentWord -ToolTip ('Value for ' + $positionals[0] + '.') -Placeholder '<value>' } } '^alias (delete|set)$' { if ($positionals.Count -eq 0) { return ConvertTo-GhCliValueResult -Values (Get-GhCliConfiguredAliasList) -CurrentWord $currentWord -ToolTip 'gh alias from config.yml.' -Placeholder '<alias>' } } '^workflow (run|view|enable|disable)$' { if ($positionals.Count -eq 0) { return ConvertTo-GhCliValueResult -Values (Get-GhCliWorkflowFileList) -CurrentWord $currentWord -ToolTip 'Workflow file under .github/workflows.' -Placeholder '<workflow>' } } '^pr checkout$' { if ($positionals.Count -eq 0) { return ConvertTo-GhCliValueResult -Values (Get-GhCliLocalBranchList) -CurrentWord $currentWord -ToolTip 'Local branch (or a PR number/URL).' -Placeholder '<number|url|branch>' } } '^(extension|extensions|ext) (remove|uninstall|upgrade)$' { if ($positionals.Count -eq 0) { $extensions = @(Get-GhCliExtensionList) if ($commandPath -like '* upgrade') { $extensions += '--all' } return ConvertTo-GhCliValueResult -Values $extensions -CurrentWord $currentWord -ToolTip 'Installed gh extension.' -Placeholder '<extension>' } } '^(secret|variable) (set|delete|remove)$' { if ($positionals.Count -eq 0) { return ConvertTo-GhCliValueResult -Values @() -CurrentWord $currentWord -ToolTip 'Secret or variable name.' -Placeholder '<NAME>' } } '^help' { # 'gh help <topic>' takes one operand; later words belong to a command path. if ($words.Count -gt 1) { return @() } $topics = @(Get-GhCliHelpTopicList) $typed = $currentWord.Trim([char[]]@([char]34, [char]39)) return @( foreach ($topic in $topics) { if ($topic.Name.StartsWith($typed, [System.StringComparison]::OrdinalIgnoreCase)) { [System.Management.Automation.CompletionResult]::new($topic.Name, $topic.Name, 'ParameterValue', $topic.Description) } } ) } } @() } function Invoke-GhCliCompletion { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $liveResults = @(Get-GhCliLiveCompletion -CommandAst $CommandAst -CursorPosition $CursorPosition) # gh's completion offers commands but not the HELP TOPICS for 'gh help <topic>', so that slot # also consults the (cheap, cached) fallback and appends the topics. $elements = $CommandAst.CommandElements $isHelp = $elements.Count -gt 1 -and $elements[1].Extent.Text -eq 'help' if ($liveResults.Count -gt 0 -and -not $isHelp) { return $liveResults } @($liveResults) + @(Get-GhCliFallbackCompletion -WordToComplete $WordToComplete -CommandAst $CommandAst -CursorPosition $CursorPosition) } Register-ArgumentCompleter -Native -CommandName @('gh', 'gh.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Invoke-GhCliCompletion -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } |