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`.
The generated completion script is resolved lazily at completion time, cached,
and then invoked through the installed GitHub CLI's own PowerShell completer.

Run once per session, or dot-source it from your PowerShell profile.
#>


Set-StrictMode -Version Latest

function Get-GhCliCommandPath {
    $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-GhCliGeneratedCompletionScript {
    # A completion callback must stay silent: diagnostics go to the verbose stream only.
    $ghCommandPath = Get-GhCliCommandPath
    if ([string]::IsNullOrWhiteSpace($ghCommandPath)) {
        Write-Verbose 'GitHub CLI (gh) was not found in PATH.'
        return $null
    }

    try {
        $completionScript = $null | & $ghCommandPath completion -s powershell 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String

        if ([string]::IsNullOrWhiteSpace($completionScript)) {
            Write-Verbose 'gh returned an empty completion script.'
            return $null
        }

        return $completionScript
    } catch {
        Write-Verbose ("Failed to load gh completion: {0}" -f $_.Exception.Message)
        $null
    }
}

function Get-GhCliCompletionInvoker {
    $cachedInvoker = Get-Variable -Name GhCliCompletionInvoker -Scope Script -ErrorAction Ignore
    if ($null -ne $cachedInvoker) {
        return $cachedInvoker.Value
    }

    # A failed discovery is cached too, so a machine without gh pays for it once per session.
    if (Get-Variable -Name GhCliCompletionUnavailable -Scope Script -ErrorAction Ignore) {
        return $null
    }

    $completionScript = Get-GhCliGeneratedCompletionScript
    if ([string]::IsNullOrWhiteSpace($completionScript)) {
        $script:GhCliCompletionUnavailable = $true
        return $null
    }

    $completionScript = $completionScript -replace (
        "(?m)^\s*Register-ArgumentCompleter\s+-CommandName\s+'gh'\s+-ScriptBlock\s+\$\{__ghCompleterBlock\}\s*\r?$"
    ), ''

    # With no suggestions $Values is $null, and piping $null still runs the filter and the emitting
    # ForEach-Object once: the filter assigns .Name on $null for '--flag=' words and the emitter builds a
    # CompletionResult from a null name, each leaving a record in $Error on such a Tab.
    $completionScript = $completionScript -replace (
        '(?m)^(\s*)\$Values = \$Values \| Where-Object \{\s*$'
    ), '$1$$Values = $$Values | Where-Object { $$null -ne $$_ } | Where-Object {'
    $completionScript = $completionScript -replace (
        '(?m)^(\s*)\$Values \| ForEach-Object \{\s*$'
    ), '$1$$Values | Where-Object { $$null -ne $$_ } | ForEach-Object {'

    # gh's block dereferences properties of an empty pipeline when there are no suggestions; run it
    # outside this script's strict mode so those accesses stay silent.
    $completionInvokerSource = @"
Set-StrictMode -Off
$completionScript

& `${__ghCompleterBlock} @args
"@


    try {
        $script:GhCliCompletionInvoker = [scriptblock]::Create($completionInvokerSource)
        return $script:GhCliCompletionInvoker
    } catch {
        Write-Verbose ("Failed to prepare gh completion: {0}" -f $_.Exception.Message)
        $script:GhCliCompletionUnavailable = $true
        $null
    }
}

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
    )

    $completionInvoker = Get-GhCliCompletionInvoker
    if ($null -eq $completionInvoker) {
        return
    }

    # gh's generated block lets the child process's 'Completion ended with directive' banner reach
    # stderr; redirecting the whole invocation keeps the console clean.
    $delegated = @()
    try {
        $delegated = @(& $completionInvoker $wordToComplete $commandAst $cursorPosition 2>$null)
    } catch {
        Write-Verbose ("gh completion block failed: {0}" -f $_.Exception.Message)
    }
    $realResults = @($delegated | Where-Object { $_ -is [System.Management.Automation.CompletionResult] })

    # 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 ($realResults.Count -gt 0 -and -not $isHelp) {
        return $realResults
    }

    $fallback = @(Get-GhCliFallbackCompletion -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition)
    if ($realResults.Count -gt 0 -or $fallback.Count -gt 0) {
        return @($realResults) + @($fallback)
    }

    # gh's "" sentinel (ShellCompDirectiveNoFileComp) is not passed on: the engine rejects an empty
    # completion text and records the failure in $Error.
}

Register-ArgumentCompleter -Native -CommandName @('gh', 'gh.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    Invoke-GhCliCompletion -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}