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?$"
    ), ''

    # 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-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
    )

    $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 { $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*' -and $previousToken -in @('-X', '--method')) {
        return ConvertTo-GhCliValueResult -Values @('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD') -CurrentWord $currentWord -ToolTip 'HTTP method for the request (default GET).'
    }

    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 = @('git_protocol', 'editor', 'prompt', 'prefer_editor_prompt', 'pager', 'http_unix_socket', 'browser', 'color_labels', 'accessible_colors', 'accessible_prompter', 'spinner')
            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') }
                    'color_labels' { @('enabled', 'disabled') }
                    'accessible_colors' { @('enabled', 'disabled') }
                    'accessible_prompter' { @('enabled', 'disabled') }
                    'spinner' { @('enabled', 'disabled') }
                    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>'
            }
        }
        '^(secret|variable) (set|delete|remove)$' {
            if ($positionals.Count -eq 0) {
                return ConvertTo-GhCliValueResult -Values @() -CurrentWord $currentWord -ToolTip 'Secret or variable name.' -Placeholder '<NAME>'
            }
        }
        '^help' {
            $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] })

    if ($realResults.Count -gt 0) {
        return $realResults
    }

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

    # Pass through gh's own "" sentinel (ShellCompDirectiveNoFileComp) or nothing (file completion allowed).
    $delegated
}

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

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