completers/agy_completer/agy_completer.ps1

# Installed-help-driven completion for the Antigravity CLI.
Set-StrictMode -Version 2.0

function Get-AgyCompletionCatalog {
    param([string]$Context = '')
    if (-not (Get-Variable -Name AgyCompletionCatalogs -Scope Script -ErrorAction Ignore)) {
        $script:AgyCompletionCatalogs = @{}
    }
    if ($script:AgyCompletionCatalogs.ContainsKey($Context)) { return $script:AgyCompletionCatalogs[$Context] }
    $options = [System.Collections.Hashtable]::new([System.StringComparer]::Ordinal)
    $commands = [ordered]@{}
    $helpText = ''
    $exe = Get-Command agy.exe -CommandType Application -ErrorAction Ignore | Select-Object -First 1
    if ($null -ne $exe) {
        # Only known help entry points are executable: plugin verbs treat --help as data.
        $arguments = @('--help')
        if ($Context -match '^mcp (add|remove|list|enable|disable)$') { $arguments = @('mcp', $Matches[1], '--help') }
        elseif ($Context -match '^remote-control (\S+)$') { $arguments = @('remote-control', $Matches[1], '--help') }
        elseif ($Context) { $arguments = @('help', ($Context -split ' ')[0]) }
        $process = [System.Diagnostics.Process]::new()
        try {
            $process.StartInfo = [System.Diagnostics.ProcessStartInfo]::new($exe.Source)
            $process.StartInfo.UseShellExecute = $false
            $process.StartInfo.CreateNoWindow = $true
            $process.StartInfo.RedirectStandardInput = $true
            $process.StartInfo.RedirectStandardOutput = $true
            $process.StartInfo.RedirectStandardError = $true
            foreach ($argument in $arguments) { [void]$process.StartInfo.ArgumentList.Add($argument) }
            [void]$process.Start()
            $process.StandardInput.Close()
            $stdout = $process.StandardOutput.ReadToEndAsync()
            $stderr = $process.StandardError.ReadToEndAsync()
            if ($process.WaitForExit(2000)) { $helpText = ($stdout.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') + "`n" + ($stderr.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') }
            else { $process.Kill($true) }
        } catch { $helpText = '' } finally { $process.Dispose() }
    }
    $section = ''
    foreach ($line in ($helpText -split '\r?\n')) {
        if ($line -match '^(Available subcommands|Commands):') { $section = 'commands'; continue }
        if ($line -match '^\S') { $section = '' }
        if ($line -match '^\s+(--?[A-Za-z0-9][A-Za-z0-9-]*)(?=(\s|,|=|\[|$))(?:[^\s]*)(?:\s+\S+)?\s{2,}(.+)$') { $options[$Matches[1]] = $Matches[3].Trim(); continue }
        if ($section -eq 'commands' -and $line -match '^\s+([a-z][a-z0-9-]*)(?: [^\r\n]*?)?\s{2,}(.+)$') { $commands[$Matches[1]] = $Matches[2].Trim() }
    }
    if (-not $helpText.Trim()) {
        $options['--help'] = 'Show help'
        if (-not $Context) { foreach ($name in @('agent','agents','changelog','help','install','mcp','mic-serve','models','plugin','plugins','update')) { $commands[$name] = "Show help for $name" } }
    }
    $catalog = @{ Options = $options; Commands = $commands }
    $script:AgyCompletionCatalogs[$Context] = $catalog
    return $catalog
}

function Get-AgyValueKind {
    param([string]$Context, [string]$Option)
    $catalog = Get-AgyCompletionCatalog $Context
    if ($catalog.Options.ContainsKey($Option)) {
        $description = $catalog.Options[$Option]
        if ($description -match '\(([a-z][a-z0-9-]*(?:\s*[,|]\s*[a-z][a-z0-9-]*)+)\)') {
            return (($Matches[1] -split '\s*[,|]\s*') -join '|')
        }
        if ($description -match "Server type: '([^']+)' or '([^']+)'") { return $Matches[1] + '|' + $Matches[2] }
    }
    if (-not $Context) {
        switch -CaseSensitive ($Option) {
            '--add-dir' { return 'directory' }
            '--log-file' { return 'file' }
            '--json-schema' { return 'schema' }
            '--effort' { return 'low|medium|high' }
            '--input-format' { return 'text|stream-json' }
            '--output-format' { return 'text|json|stream-json' }
            '--mode' { return 'accept-edits|plan' }
            '--print-timeout' { return '<duration>' }
            '--agent' { return '<agent>' }
            '--model' { return '<model>' }
            '--conversation' { return '<conversation-id>' }
            '--project' { return '<project-id-or-name>' }
            { $_ -cin @('-p','--print','--prompt','-i','--prompt-interactive') } { return '<prompt>' }
        }
    }
    if ($Context -eq 'install' -and $Option -ceq '--dir') { return 'directory' }
    if ($Context -eq 'mic-serve' -and $Option -ceq '--addr') { return '<host:port>' }
    if ($Context -eq 'remote-control start' -and $Option -ceq '--name') { return '<instance-name>' }
    if ($Context -eq 'mcp add') {
        switch -CaseSensitive ($Option) {
            { $_ -cin @('-t','--type') } { return 'stdio|http' }
            { $_ -cin @('-e','--env') } { return '<KEY=value>' }
            { $_ -cin @('-H','--header') } { return '<Key: Value>' }
        }
    }
    return ''
}

function ConvertFrom-AgyTypedWord {
    # A word opened with a quote (ASCII or typographic) is read by the PowerShell tokenizer.
    param([string]$Word)
    if ($Word -notmatch '^[''"\u2018-\u201E]') { return $Word }
    $parsedTokens = $null; $parseErrors = $null
    [void][System.Management.Automation.Language.Parser]::ParseInput($Word, [ref]$parsedTokens, [ref]$parseErrors)
    $parsedTokens[0].Value
}

function ConvertTo-AgyQuotedValue {
    # Bare when safe and no quote was typed, otherwise in the typed quote (single by default).
    # PowerShell reads ' and U+2018-U+201B as single quotes and " and U+201C-U+201E as double quotes.
    param([string]$Value, [string]$QuoteChar = '')
    if (-not $QuoteChar) {
        if ($Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]') { return $Value }
        $QuoteChar = "'"
    }
    if ($QuoteChar -match '^[''\u2018-\u201B]$') { return $QuoteChar + ($Value -replace '([''\u2018-\u201B])', '$1$1') + $QuoteChar }
    $QuoteChar + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + $QuoteChar
}

function New-AgyCompletion {
    param([string]$Text, [string]$Description, [string]$Type = 'ParameterValue', [string]$QuoteChar = '')
    [System.Management.Automation.CompletionResult]::new((ConvertTo-AgyQuotedValue $Text $QuoteChar), $Text, $Type, $Description)
}

function Get-AgyValueCompletion {
    param([string]$Kind, [string]$Word, [string]$Prefix = '')
    $quote = if ($Word -match '^[''"\u2018-\u201E]') { $Word.Substring(0, 1) } else { '' }
    $Word = ConvertFrom-AgyTypedWord $Word
    if ($Kind -eq 'schema') {
        if (-not $Word -or $Word.StartsWith('{')) { New-AgyCompletion ($Prefix + $(if ($Word) { $Word } else { '<json-schema>' })) 'Enter a JSON schema string or local schema file' -QuoteChar $quote }
        if ($Word.StartsWith('{')) { return }
        $Kind = 'file'
    }
    if ($Kind -in @('file','directory')) {
        if ($Word -match '^(?:\\\\|//)' -or $Word -match '^[^:]+::') {
            New-AgyCompletion ($Prefix + $Word) 'Enter a path; remote paths are not enumerated' -QuoteChar $quote
            return
        }
        # Keep the typed directory text (including a leading .\ or ./) exactly as typed.
        $split = $Word.LastIndexOfAny([char[]]@('/', '\'))
        $directoryText = $Word.Substring(0, $split + 1); $leaf = $Word.Substring($split + 1)
        $parent = if ($directoryText) { $directoryText } else { '.' }
        $found = $false
        if (Test-Path -LiteralPath $parent -PathType Container) {
            foreach ($entry in (Get-ChildItem -LiteralPath $parent -Force -ErrorAction Ignore)) {
                if ($Kind -eq 'directory' -and -not $entry.PSIsContainer) { continue }
                if (-not $entry.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
                $path = $directoryText + $entry.Name
                # A whole-word name starting with a dash would parse as a parameter: anchor it to the current directory.
                if (-not $directoryText -and -not $Prefix -and $path -match '^[-–-―]') { $path = '.' + [System.IO.Path]::DirectorySeparatorChar + $path }
                if ($entry.PSIsContainer) { $path += [System.IO.Path]::DirectorySeparatorChar }
                New-AgyCompletion ($Prefix + $path) $entry.FullName -QuoteChar $quote
                $found = $true
            }
        }
        if (-not $found) { New-AgyCompletion ($Prefix + $(if ($Word) { $Word } else { '<path>' })) 'Enter a local path' -QuoteChar $quote }
        return
    }
    if ($Kind.StartsWith('<')) { New-AgyCompletion ($Prefix + $(if ($Word) { $Word } else { $Kind })) "Enter $Kind" -QuoteChar $quote; return }
    $found = $false
    foreach ($value in ($Kind -split '\|')) {
        if ($value.StartsWith($Word, [System.StringComparison]::Ordinal)) { New-AgyCompletion ($Prefix + $value) $value -QuoteChar $quote; $found = $true }
    }
    if (-not $found) { New-AgyCompletion ($Prefix + $Word) "Expected: $Kind" -QuoteChar $quote }
}

function Get-AgyCompletion {
    param([string]$Word, [System.Management.Automation.Language.CommandAst]$Ast, [int]$Cursor)
    $tokens = @()
    foreach ($element in ($Ast.CommandElements | Select-Object -Skip 1)) {
        if ($element.Extent.StartOffset -ge $Cursor) { break }
        if ($element.Extent.EndOffset -ge $Cursor -and $Word) { break }
        $text = $element.Extent.Text
        if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $text = $element.Value }
        $tokens += $text
    }
    $context = ''; $pending = ''; $operands = @(); $helpMode = $false; $endOptions = $false
    $catalog = Get-AgyCompletionCatalog
    foreach ($token in $tokens) {
        if ($pending) { $pending = ''; continue }
        if ($token -ceq '--') { $endOptions = $true; continue }
        if (-not $endOptions -and $token.StartsWith('-') -and -not ($context -eq 'mcp add' -and $operands.Count)) {
            $split = $token.IndexOf('=')
            if ($split -lt 0) { $pending = Get-AgyValueKind $context $token }
            continue
        }
        if (-not $context -and -not $operands.Count -and -not $endOptions -and $catalog.Commands.Contains($token)) {
            if ($token -eq 'help') { $helpMode = $true; continue }
            $context = if ($token -eq 'plugins') { 'plugin' } else { $token }
            $catalog = Get-AgyCompletionCatalog $context
            continue
        }
        if ($context -in @('plugin','mcp','remote-control') -and -not $operands.Count -and $catalog.Commands.Contains($token)) {
            $context += ' ' + $token
            if ($context -notlike 'plugin *') { $catalog = Get-AgyCompletionCatalog $context }
            else { $catalog = @{ Options = @{}; Commands = @{} } }
            continue
        }
        $operands += $token
    }
    if ($pending) { Get-AgyValueCompletion $pending $Word; return }
    $allowOptions = -not $endOptions -and -not ($context -eq 'mcp add' -and $operands.Count) -and -not $helpMode
    if ($allowOptions -and $Word -match '^(--?[A-Za-z0-9][A-Za-z0-9-]*=)(.*)$') {
        $prefix = $Matches[1]; $value = $Matches[2]
        $kind = Get-AgyValueKind $context $prefix.TrimEnd('=')
        if ($kind) { Get-AgyValueCompletion $kind $value $prefix; return }
    }
    $found = $false
    if ($allowOptions -and ($Word.StartsWith('-') -or -not $Word)) {
        foreach ($option in ($catalog.Options.Keys | Sort-Object -CaseSensitive)) {
            if ($option.StartsWith($Word, [System.StringComparison]::Ordinal)) { New-AgyCompletion $option $catalog.Options[$option] 'ParameterName'; $found = $true }
        }
    }
    if (-not $endOptions -and -not $operands.Count) {
        foreach ($name in $catalog.Commands.Keys) {
            if ($name.StartsWith($Word, [System.StringComparison]::Ordinal)) { New-AgyCompletion $name $catalog.Commands[$name]; $found = $true }
        }
    }
    if ($found) { return }
    $kind = '<argument>'
    switch -Regex ($context) {
        '^$' { $kind = if ($helpMode) { '<command>' } else { '<prompt>' } }
        '^mcp add$' { $kind = if ($operands.Count -eq 0) { '<server-name>' } elseif ($operands.Count -eq 1) { '<command-or-url>' } else { '<command-argument>' } }
        '^mcp (remove|enable|disable)$' { $kind = '<server-name>' }
        '^plugin import$' { $kind = 'gemini|claude' }
        '^plugin validate$' { $kind = 'directory' }
        '^plugin install$' { $kind = 'directory' }
        '^plugin (uninstall|enable|disable)$' { $kind = '<plugin-name>' }
        '^plugin link$' { $kind = if ($operands.Count -eq 0) { '<marketplace>' } else { '<target>' } }
    }
    if ($context -eq 'plugin import' -and ($Word -match '[/\\.:]' -or -not $Word)) {
        Get-AgyValueCompletion 'directory' $Word
        if ($Word) { return }
    }
    if ($context -eq 'plugin install' -and (-not $Word -or $Word.Contains('@'))) {
        New-AgyCompletion $(if ($Word) { $Word } else { '<plugin@marketplace>' }) 'Enter a plugin@marketplace target or local directory'
        if ($Word) { return }
    }
    Get-AgyValueCompletion $kind $Word
}

Register-ArgumentCompleter -Native -CommandName @('agy', 'agy.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Get-AgyCompletion -Word $wordToComplete -Ast $commandAst -Cursor $cursorPosition
}