completers/pi_completer/pi_completer.ps1
|
<# .SYNOPSIS Registers a native PowerShell argument completer for pi. .DESCRIPTION Provides a hybrid, static-first native argument completer for `pi`, `pi.cmd`, and `pi.ps1`. The completer covers: - top-level `pi` subcommands and global options - command-specific options for install/remove/uninstall/update/list/config - inline `--option=value` completion - path-aware completion for session, export, extension, skill, prompt, and theme paths - `@file` root-argument completion - enums for mode, thinking level, tools, and provider names - local custom provider/model discovery from `models.json` - source-scheme and local-path hints for install/remove/update The script is safe to dot-source multiple times and keeps its top level compatible with `Import-CompleterScript`. #> Set-StrictMode -Version Latest function New-PiCompletionResult { param( [string]$CompletionText, [string]$ResultType = 'ParameterValue', [string]$ToolTip, [string]$ListItemText ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $ListItemText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function New-PiOptionSpec { param( [string[]]$Tokens, [string]$Description, [string]$ValueKind, [switch]$OptionalValue, [string]$CompletionText, [string]$Placeholder ) foreach ($token in @($Tokens)) { [pscustomobject]@{ Token = $token Description = $Description ValueKind = $ValueKind OptionalValue = [bool]$OptionalValue CompletionText = if ([string]::IsNullOrWhiteSpace($CompletionText)) { $token } else { $CompletionText } Placeholder = $Placeholder } } } function New-PiCommandSpec { param( [string]$Name, [string]$Description, [string[]]$Positionals, [object[]]$Options ) [pscustomobject]@{ Name = $Name Description = $Description Positionals = @($Positionals) Options = @($Options) } } function Test-PiCacheFresh { param( [datetime]$LoadedAt, [int]$TtlSeconds ) if ($LoadedAt -eq [datetime]::MinValue) { return $false } ((Get-Date) - $LoadedAt).TotalSeconds -lt $TtlSeconds } function Get-PiUniqueStrings { param([string[]]$Items) $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $results = New-Object System.Collections.Generic.List[string] foreach ($item in @($Items)) { if ([string]::IsNullOrWhiteSpace($item)) { continue } if ($seen.Add($item)) { [void]$results.Add($item) } } @($results.ToArray()) } function Get-PiCompletionCache { if (-not (Get-Variable -Name PiCompletionCache -Scope Script -ErrorAction Ignore)) { $installLikeOptions = @( New-PiOptionSpec -Tokens @('-l', '--local') -Description 'Use project-local .pi/settings.json.' New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show subcommand help.' ) $globalOptions = @( New-PiOptionSpec -Tokens @('--provider') -Description 'Provider name.' -ValueKind 'Provider' New-PiOptionSpec -Tokens @('--model') -Description 'Model pattern or ID.' -ValueKind 'ModelPattern' New-PiOptionSpec -Tokens @('--api-key') -Description 'API key override.' -ValueKind 'ApiKey' New-PiOptionSpec -Tokens @('--system-prompt') -Description 'Replace the default system prompt.' -ValueKind 'SystemPrompt' New-PiOptionSpec -Tokens @('--append-system-prompt') -Description 'Append text or file contents to the system prompt.' -ValueKind 'TextOrFile' New-PiOptionSpec -Tokens @('--mode') -Description 'Output mode.' -ValueKind 'Mode' New-PiOptionSpec -Tokens @('--print', '-p') -Description 'Run in non-interactive mode and exit.' New-PiOptionSpec -Tokens @('--continue', '-c') -Description 'Continue the previous session.' New-PiOptionSpec -Tokens @('--resume', '-r') -Description 'Select a session to resume.' New-PiOptionSpec -Tokens @('--session') -Description 'Use a specific session file.' -ValueKind 'SessionPathOrId' New-PiOptionSpec -Tokens @('--fork') -Description 'Fork a session file or partial UUID into a new session.' -ValueKind 'SessionPathOrId' New-PiOptionSpec -Tokens @('--session-dir') -Description 'Directory for session storage and lookup.' -ValueKind 'DirectoryPath' New-PiOptionSpec -Tokens @('--session-id') -Description 'Use exact project session ID, creating it if missing.' -ValueKind 'SessionId' New-PiOptionSpec -Tokens @('--no-session') -Description 'Do not save the session.' New-PiOptionSpec -Tokens @('--name', '-n') -Description 'Set session display name.' -ValueKind 'SessionName' New-PiOptionSpec -Tokens @('--models') -Description 'Comma-separated model patterns for Ctrl+P cycling.' -ValueKind 'ModelPatternList' New-PiOptionSpec -Tokens @('--no-tools', '-nt') -Description 'Disable all built-in and extension tools.' New-PiOptionSpec -Tokens @('--no-builtin-tools', '-nbt') -Description 'Disable built-in tools but keep extension/custom tools enabled.' New-PiOptionSpec -Tokens @('--tools', '-t') -Description 'Comma-separated list of tool names to enable.' -ValueKind 'ToolList' New-PiOptionSpec -Tokens @('--exclude-tools', '-xt') -Description 'Comma-separated denylist of tool names to disable.' -ValueKind 'ToolList' New-PiOptionSpec -Tokens @('--thinking') -Description 'Set the thinking level.' -ValueKind 'Thinking' New-PiOptionSpec -Tokens @('--extension', '-e') -Description 'Load an extension file or directory.' -ValueKind 'ExtensionPath' New-PiOptionSpec -Tokens @('--no-extensions', '-ne') -Description 'Disable extension discovery.' New-PiOptionSpec -Tokens @('--skill') -Description 'Load a skill file or directory.' -ValueKind 'SkillPath' New-PiOptionSpec -Tokens @('--no-skills', '-ns') -Description 'Disable skill discovery and loading.' New-PiOptionSpec -Tokens @('--prompt-template') -Description 'Load a prompt template file or directory.' -ValueKind 'PromptTemplatePath' New-PiOptionSpec -Tokens @('--no-prompt-templates', '-np') -Description 'Disable prompt template discovery and loading.' New-PiOptionSpec -Tokens @('--theme') -Description 'Load a theme file or directory.' -ValueKind 'ThemePath' New-PiOptionSpec -Tokens @('--use-theme') -Description 'Set the initial interactive theme for this run.' -ValueKind 'ThemeName' New-PiOptionSpec -Tokens @('--no-themes') -Description 'Disable theme discovery and loading.' New-PiOptionSpec -Tokens @('--no-context-files', '-nc') -Description 'Disable AGENTS.md and CLAUDE.md discovery and loading.' New-PiOptionSpec -Tokens @('--export') -Description 'Export a session to HTML.' -ValueKind 'ExportInputPath' New-PiOptionSpec -Tokens @('--list-models') -Description 'List available models, optionally filtered by search text.' -ValueKind 'ModelSearch' -OptionalValue New-PiOptionSpec -Tokens @('--verbose') -Description 'Force verbose startup.' New-PiOptionSpec -Tokens @('--tui-mode') -Description 'TUI mode: regular (default) or fullscreen.' -ValueKind 'TuiMode' New-PiOptionSpec -Tokens @('--offline') -Description 'Disable startup network operations.' New-PiOptionSpec -Tokens @('--') -Description 'End option parsing; treat remaining arguments as messages/files.' New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show help.' New-PiOptionSpec -Tokens @('--version', '-v') -Description 'Show version.' ) $commandSpecs = @( New-PiCommandSpec -Name 'install' -Description 'Install extension source and add to settings.' -Positionals @('PackageSource') -Options $installLikeOptions New-PiCommandSpec -Name 'remove' -Description 'Remove extension source from settings.' -Positionals @('InstalledPackageSource') -Options $installLikeOptions New-PiCommandSpec -Name 'uninstall' -Description 'Alias for remove.' -Positionals @('InstalledPackageSource') -Options $installLikeOptions New-PiCommandSpec -Name 'update' -Description 'Update pi and installed extensions.' -Positionals @('UpdateTarget') -Options @( New-PiOptionSpec -Tokens @('--self') -Description 'Update pi only.' New-PiOptionSpec -Tokens @('--extensions') -Description 'Update installed extensions only.' New-PiOptionSpec -Tokens @('--extension') -Description 'Update one installed extension only.' -ValueKind 'InstalledPackageSource' New-PiOptionSpec -Tokens @('--force') -Description 'Reinstall pi even if already current.' New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show subcommand help.' ) New-PiCommandSpec -Name 'list' -Description 'List installed extensions from settings.' -Positionals @() -Options @( New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show subcommand help.' ) New-PiCommandSpec -Name 'config' -Description 'Open package-resource TUI.' -Positionals @() -Options @( New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show subcommand help if supported.' ) New-PiCommandSpec -Name 'auth' -Description 'Print credentials or check provider readiness.' -Positionals @('AuthCommand') -Options @( New-PiOptionSpec -Tokens @('--provider') -Description 'Provider to print credentials for or check.' -ValueKind 'Provider' New-PiOptionSpec -Tokens @('--model') -Description 'Model whose provider to print credentials for or check.' -ValueKind 'ModelPattern' New-PiOptionSpec -Tokens @('--min-expiry') -Description 'Minimum remaining bearer token lifetime (print-bearer-token).' -ValueKind 'Placeholder' -Placeholder '<duration>' New-PiOptionSpec -Tokens @('--json') -Description 'Emit JSON output (check).' New-PiOptionSpec -Tokens @('--credentials') -Description 'Emit the credential or include it in JSON output (check).' New-PiOptionSpec -Tokens @('--no-refresh') -Description 'Do not refresh expired OAuth credentials (check).' New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show subcommand help.' ) ) $commandLookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($commandSpec in $commandSpecs) { $commandLookup[$commandSpec.Name] = $commandSpec } $staticCommandLookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($commandSpec in $commandSpecs) { $staticCommandLookup[$commandSpec.Name] = $commandSpec } $script:PiCompletionCache = @{ RootCommands = $commandSpecs CommandLookup = $commandLookup GlobalOptions = $globalOptions StaticRootCommands = $commandSpecs StaticCommandLookup = $staticCommandLookup StaticGlobalOptions = $globalOptions BuiltInProviders = @( 'anthropic', 'openai', 'openai-codex', 'github-copilot', 'google', 'azure-openai-responses', 'google-vertex', 'amazon-bedrock', 'mistral', 'groq', 'cerebras', 'xai', 'openrouter', 'vercel-ai-gateway', 'zai', 'zai-coding-cn', 'opencode', 'opencode-go', 'huggingface', 'kimi-coding', 'minimax', 'minimax-cn', 'deepseek', 'nvidia', 'fireworks', 'together', 'baseten', 'ant-ling', 'moonshotai', 'moonshotai-cn', 'cloudflare-ai-gateway', 'cloudflare-workers-ai', 'qwen-token-plan', 'qwen-token-plan-cn', 'qwen-token-plan-individual', 'xiaomi', 'xiaomi-token-plan-ams', 'xiaomi-token-plan-cn', 'xiaomi-token-plan-sgp' ) BuiltInTools = @('read', 'bash', 'edit', 'write', 'grep', 'find', 'ls') ThinkingLevels = @('off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max') OutputModes = @('text', 'json', 'rpc') TuiModes = @('regular', 'fullscreen') AuthCommands = @('print-api-key', 'print-bearer-token', 'check') ExecutablePath = $null ExecutablePathProbed = $false CustomModelDataLoadedAt = [datetime]::MinValue CustomModelDataTtlSeconds = 120 CustomProviderNames = @() CustomModelCandidates = @() RootHelpLoadedAt = [datetime]::MinValue RootHelpTtlSeconds = 60 CommandHelpLoadedAt = @{ install = [datetime]::MinValue remove = [datetime]::MinValue uninstall = [datetime]::MinValue update = [datetime]::MinValue list = [datetime]::MinValue } CommandHelpTtlSeconds = 60 InstalledPackageSourcesLoadedAt = [datetime]::MinValue InstalledPackageSourcesTtlSeconds = 60 InstalledPackageSources = @() SessionFilesLoadedAt = [datetime]::MinValue SessionFilesTtlSeconds = 60 SessionFiles = @() ResourcePathsLoadedAt = @{ extension = [datetime]::MinValue skill = [datetime]::MinValue 'prompt-template' = [datetime]::MinValue theme = [datetime]::MinValue } ResourcePathsTtlSeconds = 60 ResourcePaths = @{ extension = @() skill = @() 'prompt-template' = @() theme = @() } } } $script:PiCompletionCache } function Resolve-PiExecutablePath { $cache = Get-PiCompletionCache if ($cache.ExecutablePathProbed) { return $cache.ExecutablePath } $cache.ExecutablePathProbed = $true $cache.ExecutablePath = $null foreach ($commandName in @('pi.ps1', 'pi.cmd', 'pi')) { $command = Get-Command -Name $commandName -ErrorAction SilentlyContinue | Select-Object -First 1 if ($command) { $cache.ExecutablePath = if ($command.Source) { $command.Source } else { $command.Name } break } } $cache.ExecutablePath } function ConvertTo-PiCommandLineArgument { param([string]$Argument) if ($null -eq $Argument) { return '""' } if ($Argument -notmatch '[\s"&]') { return $Argument } '"' + ($Argument -replace '"', '\"') + '"' } function Invoke-PiCapture { param([string[]]$Arguments) $executablePath = Resolve-PiExecutablePath if ([string]::IsNullOrWhiteSpace($executablePath)) { return @() } try { $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.UseShellExecute = $false $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.Environment['PI_OFFLINE'] = '1' $startInfo.Environment['FORCE_COLOR'] = '0' $startInfo.Environment['NO_COLOR'] = '1' if ($executablePath.EndsWith('.ps1', [System.StringComparison]::OrdinalIgnoreCase)) { $startInfo.FileName = (Get-Command -Name pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source) if ([string]::IsNullOrWhiteSpace($startInfo.FileName)) { return @() } [void]$startInfo.ArgumentList.Add('-NoProfile') [void]$startInfo.ArgumentList.Add('-File') [void]$startInfo.ArgumentList.Add($executablePath) foreach ($argument in @($Arguments)) { [void]$startInfo.ArgumentList.Add($argument) } } elseif ($executablePath.EndsWith('.cmd', [System.StringComparison]::OrdinalIgnoreCase)) { $startInfo.FileName = $env:ComSpec if ([string]::IsNullOrWhiteSpace($startInfo.FileName)) { return @() } $cmdArguments = @( ConvertTo-PiCommandLineArgument -Argument $executablePath ) + @( foreach ($argument in @($Arguments)) { ConvertTo-PiCommandLineArgument -Argument $argument } ) [void]$startInfo.ArgumentList.Add('/d') [void]$startInfo.ArgumentList.Add('/c') [void]$startInfo.ArgumentList.Add(($cmdArguments -join ' ')) } else { $startInfo.FileName = $executablePath foreach ($argument in @($Arguments)) { [void]$startInfo.ArgumentList.Add($argument) } } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo [void]$process.Start() $process.StandardInput.Close() $outputTask = $process.StandardOutput.ReadToEndAsync() $errorTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(10000)) { try { $process.Kill($true) } catch { } return @() } $standardOutput = ($outputTask.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') $null = $errorTask.GetAwaiter().GetResult() if ([string]::IsNullOrWhiteSpace($standardOutput)) { return @() } @($standardOutput -split "`r?`n") } catch { @() } } function ConvertTo-PiPlainText { param([string[]]$InputLines) $text = [string]::Join("`n", @( foreach ($line in @($InputLines)) { if ($null -eq $line) { continue } $line.ToString() } )) if ([string]::IsNullOrWhiteSpace($text)) { return '' } $text = [regex]::Replace($text, '\x1b\[[0-9;?]*[ -/]*[@-~]', '') $text = $text -replace "`a", '' $text = $text -replace "`r`n?", "`n" foreach ($heading in @( 'Usage:', 'Commands:', 'Options:', 'Extension CLI Flags:', 'Short forms:', 'Examples:', 'Environment Variables:', 'Built-in Tool Names:' )) { $text = $text -replace "(?<!`n)$([regex]::Escape($heading))", "`n$heading" } $text.Trim("`r", "`n") } function Get-PiHelpSectionText { param( [string]$Text, [string]$Heading, [string[]]$NextHeadings ) if ([string]::IsNullOrWhiteSpace($Text)) { return '' } $escapedHeading = [regex]::Escape($Heading) $escapedNextHeadings = @( foreach ($nextHeading in @($NextHeadings)) { if ([string]::IsNullOrWhiteSpace($nextHeading)) { continue } [regex]::Escape($nextHeading) } ) $terminatorPattern = if ($escapedNextHeadings.Count -gt 0) { "(?=`n(?:$($escapedNextHeadings -join '|'))|\z)" } else { '\z' } $match = [regex]::Match($Text, "(?ms)`n?$escapedHeading[ \t]*`n?(?<body>.*?)$terminatorPattern") if (-not $match.Success) { return '' } $match.Groups['body'].Value.Trim("`r", "`n") } function Remove-PiJsonComments { param([string]$Content) if ([string]::IsNullOrWhiteSpace($Content)) { return $Content } $builder = [System.Text.StringBuilder]::new($Content.Length) $inString = $false $escaped = $false $inLineComment = $false $inBlockComment = $false for ($index = 0; $index -lt $Content.Length; $index++) { $character = $Content[$index] $nextCharacter = if (($index + 1) -lt $Content.Length) { $Content[$index + 1] } else { [char]0 } if ($inLineComment) { if ($character -eq "`r" -or $character -eq "`n") { $inLineComment = $false [void]$builder.Append($character) } continue } if ($inBlockComment) { if ($character -eq '*' -and $nextCharacter -eq '/') { $inBlockComment = $false $index++ continue } if ($character -eq "`r" -or $character -eq "`n") { [void]$builder.Append($character) } continue } if ($inString) { [void]$builder.Append($character) if ($escaped) { $escaped = $false continue } if ($character -eq '\') { $escaped = $true continue } if ($character -eq '"') { $inString = $false } continue } if ($character -eq '"') { $inString = $true [void]$builder.Append($character) continue } if ($character -eq '/' -and $nextCharacter -eq '/') { $inLineComment = $true $index++ continue } if ($character -eq '/' -and $nextCharacter -eq '*') { $inBlockComment = $true $index++ continue } [void]$builder.Append($character) } $builder.ToString() } function ConvertFrom-PiJsonConfig { param( [string]$Path, [string]$Content ) if ([System.IO.Path]::GetExtension($Path).Equals('.jsonc', [System.StringComparison]::OrdinalIgnoreCase)) { $documentOptions = [System.Text.Json.JsonDocumentOptions]::new() $documentOptions.CommentHandling = [System.Text.Json.JsonCommentHandling]::Skip $documentOptions.AllowTrailingCommas = $true $document = [System.Text.Json.JsonDocument]::Parse($Content, $documentOptions) try { return ($document.RootElement.GetRawText() | ConvertFrom-Json -ErrorAction Stop) } finally { $document.Dispose() } } $Content | ConvertFrom-Json -ErrorAction Stop } function Get-PiValueKindForOption { param( [string[]]$Tokens, [string]$Placeholder, [string]$Description, [string]$CommandName ) $primaryToken = $Tokens | Where-Object { $_ -like '--*' } | Select-Object -First 1 if (-not $primaryToken) { $primaryToken = $Tokens | Select-Object -First 1 } switch ($primaryToken) { '--provider' { return 'Provider' } '--model' { return 'ModelPattern' } '--api-key' { return 'ApiKey' } '--system-prompt' { return 'SystemPrompt' } '--append-system-prompt' { return 'TextOrFile' } '--mode' { return 'Mode' } '--session' { return 'SessionPathOrId' } '--fork' { return 'SessionPathOrId' } '--session-dir' { return 'DirectoryPath' } '--session-id' { return 'SessionId' } '--name' { return 'SessionName' } '--models' { if ($CommandName -eq 'update') { return $null } return 'ModelPatternList' } '--tools' { return 'ToolList' } '--exclude-tools' { return 'ToolList' } '--thinking' { return 'Thinking' } '--use-theme' { return 'ThemeName' } '--tui-mode' { return 'TuiMode' } '--auto-model' { return 'ModelPattern' } '--extension' { if ($CommandName -eq 'update') { return 'InstalledPackageSource' } return 'ExtensionPath' } '--skill' { return 'SkillPath' } '--prompt-template' { return 'PromptTemplatePath' } '--theme' { return 'ThemePath' } '--export' { return 'ExportInputPath' } '--list-models' { return 'ModelSearch' } '--mcp-config' { return 'FilePath' } } if (-not [string]::IsNullOrWhiteSpace($Placeholder)) { $placeholderText = $Placeholder.Trim('<>[]') if ($placeholderText -match 'dir') { return 'DirectoryPath' } if ($placeholderText -match 'path|file|config') { return 'FilePath' } } if ($Description -match '\b(path|file|config)\b') { return 'FilePath' } if (-not [string]::IsNullOrWhiteSpace($Placeholder)) { return 'Placeholder' } $null } function ConvertFrom-PiHelpOptionSection { param( [string]$SectionText, [string]$CommandName ) $results = New-Object System.Collections.Generic.List[object] foreach ($line in @($SectionText -split "`n")) { if ($line -notmatch '^\s{2,}(?<spec>--[A-Za-z0-9][A-Za-z0-9-]*(?:,\s*-[A-Za-z0-9]+)?(?:\s+(?:<[^>]+>|\[[^\]]+\]))?|-[A-Za-z0-9]+(?:,\s*--[A-Za-z0-9][A-Za-z0-9-]*)?(?:\s+(?:<[^>]+>|\[[^\]]+\]))?)\s+(?<description>.+)$') { continue } $specText = $Matches.spec.Trim() $description = $Matches.description.Trim() $optionalValue = $false $placeholder = $null if ($specText -match '\s+(?<placeholder><[^>]+>|\[[^\]]+\])$') { $placeholder = $Matches.placeholder $optionalValue = $placeholder.StartsWith('[') $specText = $specText.Substring(0, $specText.Length - $placeholder.Length).TrimEnd() } $tokens = @( foreach ($token in @($specText -split '\s*,\s*')) { if ([string]::IsNullOrWhiteSpace($token)) { continue } $token.Trim() } ) if ($tokens.Count -eq 0) { continue } $valueKind = Get-PiValueKindForOption -Tokens $tokens -Placeholder $placeholder -Description $description -CommandName $CommandName foreach ($item in @(New-PiOptionSpec -Tokens $tokens -Description $description -ValueKind $valueKind -OptionalValue:$optionalValue -Placeholder $placeholder)) { [void]$results.Add($item) } } @($results.ToArray()) } function Merge-PiOptionSpecs { param( [object[]]$BaseOptions, [object[]]$OverrideOptions ) $lookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) $order = New-Object System.Collections.Generic.List[string] foreach ($option in @($BaseOptions + $OverrideOptions)) { if ($null -eq $option -or [string]::IsNullOrWhiteSpace($option.Token)) { continue } if (-not $lookup.ContainsKey($option.Token)) { [void]$order.Add($option.Token) } $lookup[$option.Token] = $option } $results = New-Object System.Collections.Generic.List[object] foreach ($token in @($order)) { [void]$results.Add($lookup[$token]) } @($results.ToArray()) } function Set-PiCommandSpec { param([object]$CommandSpec) if ($null -eq $CommandSpec -or [string]::IsNullOrWhiteSpace($CommandSpec.Name)) { return } $cache = Get-PiCompletionCache $cache.CommandLookup[$CommandSpec.Name] = $CommandSpec $updatedCommands = New-Object System.Collections.Generic.List[object] $replaced = $false foreach ($existingCommand in @($cache.RootCommands)) { if ($existingCommand.Name.Equals($CommandSpec.Name, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$updatedCommands.Add($CommandSpec) $replaced = $true } else { [void]$updatedCommands.Add($existingCommand) } } if (-not $replaced) { [void]$updatedCommands.Add($CommandSpec) } $cache.RootCommands = @($updatedCommands.ToArray()) } function ConvertFrom-PiEnumeratedDescription { param([string]$Description) if ([string]::IsNullOrWhiteSpace($Description) -or $Description -notmatch ':\s*(?<list>[^:]+)$') { return @() } $listText = $Matches.list -replace '\([^)]*\)', '' $listText = $listText -replace '\bor\b', ',' $values = New-Object System.Collections.Generic.List[string] foreach ($part in @($listText -split ',')) { $candidate = $part.Trim().TrimEnd('.') if ([string]::IsNullOrWhiteSpace($candidate)) { continue } if ($candidate -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') { return @() } [void]$values.Add($candidate) } @($values.ToArray()) } function Update-PiRootHelpData { $cache = Get-PiCompletionCache if (Test-PiCacheFresh -LoadedAt $cache.RootHelpLoadedAt -TtlSeconds $cache.RootHelpTtlSeconds) { return } $cache.RootHelpLoadedAt = Get-Date $text = ConvertTo-PiPlainText -InputLines (Invoke-PiCapture -Arguments @('--help')) if ([string]::IsNullOrWhiteSpace($text)) { return } $commandSpecs = @($cache.StaticRootCommands) $commandLookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($commandSpec in @($commandSpecs)) { $commandLookup[$commandSpec.Name] = $commandSpec } $commandsSection = Get-PiHelpSectionText -Text $text -Heading 'Commands:' -NextHeadings @('Options:') foreach ($line in @($commandsSection -split "`n")) { if ($line -notmatch '^\s*pi\s+(?<name>[A-Za-z0-9-]+)(?:\s+(?<usage>.*?))?\s{2,}(?<description>.+)$') { continue } $commandName = $Matches.name $description = $Matches.description.Trim() $existingCommand = if ($commandLookup.ContainsKey($commandName)) { $commandLookup[$commandName] } else { $null } if ($existingCommand) { $commandLookup[$commandName] = New-PiCommandSpec -Name $existingCommand.Name -Description $description -Positionals $existingCommand.Positionals -Options $existingCommand.Options } else { $commandLookup[$commandName] = New-PiCommandSpec -Name $commandName -Description $description -Positionals @() -Options @( New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show subcommand help.' ) } } $globalOptions = Merge-PiOptionSpecs ` -BaseOptions $cache.StaticGlobalOptions ` -OverrideOptions (ConvertFrom-PiHelpOptionSection -SectionText (Get-PiHelpSectionText -Text $text -Heading 'Options:' -NextHeadings @('Extension CLI Flags:', 'Examples:', 'Environment Variables:', 'Built-in Tool Names:')) -CommandName '') $globalOptions = Merge-PiOptionSpecs ` -BaseOptions $globalOptions ` -OverrideOptions (ConvertFrom-PiHelpOptionSection -SectionText (Get-PiHelpSectionText -Text $text -Heading 'Extension CLI Flags:' -NextHeadings @('Examples:', 'Environment Variables:', 'Built-in Tool Names:')) -CommandName '') $builtInTools = New-Object System.Collections.Generic.List[string] $builtInToolSection = Get-PiHelpSectionText -Text $text -Heading 'Built-in Tool Names:' -NextHeadings @() foreach ($line in @($builtInToolSection -split "`n")) { if ($line -match '^\s*(?<tool>[A-Za-z0-9-]+)\s+-') { [void]$builtInTools.Add($Matches.tool) } } foreach ($enumOption in @( @{ Token = '--thinking'; Key = 'ThinkingLevels' }, @{ Token = '--mode'; Key = 'OutputModes' }, @{ Token = '--tui-mode'; Key = 'TuiModes' } )) { $spec = Find-PiOptionSpec -Token $enumOption.Token -Options $globalOptions if ($null -eq $spec) { continue } $values = @(ConvertFrom-PiEnumeratedDescription -Description $spec.Description) if ($values.Count -ge 2) { $cache[$enumOption.Key] = $values } } $cache.CommandLookup = $commandLookup $cache.RootCommands = @($commandLookup.Values | Sort-Object { $index = [array]::IndexOf(@($cache.StaticRootCommands.Name), $_.Name) if ($index -lt 0) { [int]::MaxValue } else { $index } }) $cache.GlobalOptions = $globalOptions if ($builtInTools.Count -gt 0) { $cache.BuiltInTools = Get-PiUniqueStrings -Items @($builtInTools.ToArray()) } } function Update-PiCommandHelpData { param([string]$CommandName) if ([string]::IsNullOrWhiteSpace($CommandName) -or $CommandName -eq 'config') { return } $cache = Get-PiCompletionCache if (-not $cache.CommandHelpLoadedAt.ContainsKey($CommandName)) { return } if (Test-PiCacheFresh -LoadedAt $cache.CommandHelpLoadedAt[$CommandName] -TtlSeconds $cache.CommandHelpTtlSeconds) { return } $cache.CommandHelpLoadedAt[$CommandName] = Get-Date $text = ConvertTo-PiPlainText -InputLines (Invoke-PiCapture -Arguments @($CommandName, '--help')) if ([string]::IsNullOrWhiteSpace($text)) { return } $baseCommandSpec = if ($cache.CommandLookup.ContainsKey($CommandName)) { $cache.CommandLookup[$CommandName] } elseif ($cache.StaticCommandLookup.ContainsKey($CommandName)) { $cache.StaticCommandLookup[$CommandName] } else { $null } if (-not $baseCommandSpec) { return } $description = $baseCommandSpec.Description $lines = @($text -split "`n") for ($index = 0; $index -lt $lines.Count; $index++) { if ($lines[$index].Trim() -ne 'Usage:') { continue } $usageLineSeen = $false for ($bodyIndex = $index + 1; $bodyIndex -lt $lines.Count; $bodyIndex++) { $bodyLine = $lines[$bodyIndex].Trim() if ([string]::IsNullOrWhiteSpace($bodyLine)) { continue } if ($bodyLine -like 'Options:*' -or $bodyLine -like 'Short forms:*' -or $bodyLine -like 'Examples:*') { break } if (-not $usageLineSeen) { $usageLineSeen = $true continue } $description = $bodyLine break } break } $positionals = switch ($CommandName) { 'install' { @('PackageSource') } 'remove' { @('InstalledPackageSource') } 'uninstall' { @('InstalledPackageSource') } 'update' { @('UpdateTarget') } default { @() } } $optionSection = Get-PiHelpSectionText -Text $text -Heading 'Options:' -NextHeadings @('Short forms:', 'Examples:') $options = Merge-PiOptionSpecs -BaseOptions $baseCommandSpec.Options -OverrideOptions (ConvertFrom-PiHelpOptionSection -SectionText $optionSection -CommandName $CommandName) Set-PiCommandSpec -CommandSpec (New-PiCommandSpec -Name $CommandName -Description $description -Positionals $positionals -Options $options) } function Get-PiRootCommands { Update-PiRootHelpData (Get-PiCompletionCache).RootCommands } function Get-PiGlobalOptions { Update-PiRootHelpData (Get-PiCompletionCache).GlobalOptions } function Get-PiInstalledPackageSources { $cache = Get-PiCompletionCache if (Test-PiCacheFresh -LoadedAt $cache.InstalledPackageSourcesLoadedAt -TtlSeconds $cache.InstalledPackageSourcesTtlSeconds) { return $cache.InstalledPackageSources } $sources = New-Object System.Collections.Generic.List[string] $text = ConvertTo-PiPlainText -InputLines (Invoke-PiCapture -Arguments @('list')) foreach ($line in @($text -split "`n")) { if ($line -notmatch '^\s{2}(?!\s)(?<source>.+)$') { continue } $source = $Matches.source.Trim() if ([string]::IsNullOrWhiteSpace($source) -or $source -like 'User packages:' -or $source -like 'Project packages:' -or $source -like 'No packages installed.*' -or $source -match '^[A-Za-z]:[\\/]' -or $source -like ':*') { continue } [void]$sources.Add($source) } $cache.InstalledPackageSources = Get-PiUniqueStrings -Items @($sources.ToArray()) $cache.InstalledPackageSourcesLoadedAt = Get-Date $cache.InstalledPackageSources } function Get-PiTokenText { param([System.Management.Automation.Language.Ast]$Element) if ($Element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return $Element.Value } if ($Element -is [System.Management.Automation.Language.CommandParameterAst]) { return $Element.Extent.Text } $Element.Extent.Text } function Get-PiProcessedTokens { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # Only elements that end strictly before the cursor precede the current # word; the element the cursor touches is the word being completed and # anything after the cursor is ignored. $tokens = @( foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) { if ($null -eq $element -or $element.Extent.EndOffset -ge $CursorPosition) { continue } Get-PiTokenText -Element $element } ) @($tokens) } function Get-PiCurrentWord { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) { $extent = $element.Extent if ($extent.StartOffset -ge $CursorPosition -or $extent.EndOffset -lt $CursorPosition) { continue } if ($extent.EndOffset -eq $CursorPosition -and $element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return $WordToComplete } # Comma lists parse as array literals (or error expressions); the raw # extent text keeps the typed value intact. return $extent.Text.Substring(0, $CursorPosition - $extent.StartOffset).TrimStart([char[]]@([char]34, [char]39)) } if ($CursorPosition -gt $CommandAst.Extent.EndOffset) { return '' } $WordToComplete } function Get-PiCommandSpec { param([string]$CommandName) if ([string]::IsNullOrWhiteSpace($CommandName)) { return $null } Update-PiRootHelpData Update-PiCommandHelpData -CommandName $CommandName $cache = Get-PiCompletionCache if ($cache.CommandLookup.ContainsKey($CommandName)) { return $cache.CommandLookup[$CommandName] } $null } function Find-PiOptionSpec { param( [string]$Token, [object[]]$Options ) if ([string]::IsNullOrWhiteSpace($Token)) { return $null } $normalizedToken = if ($Token.Contains('=')) { $Token.Substring(0, $Token.IndexOf('=')) } else { $Token } foreach ($option in @($Options)) { if ($option.Token.TrimEnd('=').Equals($normalizedToken, [System.StringComparison]::OrdinalIgnoreCase)) { return $option } } $null } function Test-PiPathLike { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $false } $Value -match '^(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\|/)' -or $Value.Contains('\') -or $Value.Contains('/') } function Get-PiPathCompletions { param( [string]$PathPrefix, [switch]$DirectoriesOnly, [string]$CompletionPrefix = '' ) $items = [System.Management.Automation.CompletionCompleters]::CompleteFilename($PathPrefix) foreach ($item in @($items)) { if ($DirectoriesOnly -and -not (Test-Path -LiteralPath $item.CompletionText -PathType Container)) { continue } if ([string]::IsNullOrEmpty($CompletionPrefix)) { $item continue } New-PiCompletionResult ` -CompletionText "$CompletionPrefix$($item.CompletionText)" ` -ListItemText $item.ListItemText ` -ResultType $item.ResultType ` -ToolTip $item.ToolTip } } function Get-PiAtFileCompletions { param([string]$WordToComplete) $pathPrefix = if ($WordToComplete.Length -gt 1) { $WordToComplete.Substring(1) } else { '' } Get-PiPathCompletions -PathPrefix $pathPrefix -CompletionPrefix '@' } function Get-PiModelsJsonPaths { $paths = New-Object System.Collections.Generic.List[string] $homePath = [Environment]::GetFolderPath('UserProfile') if (-not [string]::IsNullOrWhiteSpace($homePath)) { [void]$paths.Add((Join-Path $homePath '.pi\agent\models.json')) [void]$paths.Add((Join-Path $homePath '.pi\agent\models.jsonc')) } [void]$paths.Add((Join-Path (Get-Location) '.pi\models.json')) [void]$paths.Add((Join-Path (Get-Location) '.pi\models.jsonc')) Get-PiUniqueStrings -Items $paths } function Update-PiCustomModelData { $cache = Get-PiCompletionCache if (Test-PiCacheFresh -LoadedAt $cache.CustomModelDataLoadedAt -TtlSeconds $cache.CustomModelDataTtlSeconds) { return } $providerNames = New-Object System.Collections.Generic.List[string] $modelCandidates = New-Object System.Collections.Generic.List[string] foreach ($path in @(Get-PiModelsJsonPaths)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } try { $content = Get-Content -LiteralPath $path -Raw -ErrorAction Stop $config = ConvertFrom-PiJsonConfig -Path $path -Content $content if ($null -eq $config -or $null -eq $config.PSObject.Properties['providers'] -or $null -eq $config.providers) { continue } foreach ($providerProperty in @($config.providers.PSObject.Properties)) { $providerName = $providerProperty.Name if ([string]::IsNullOrWhiteSpace($providerName)) { continue } [void]$providerNames.Add($providerName) $providerValue = $providerProperty.Value if ($null -eq $providerValue -or $null -eq $providerValue.PSObject.Properties['models']) { continue } foreach ($model in @($providerValue.models)) { if ($null -eq $model -or $null -eq $model.PSObject.Properties['id']) { continue } $modelId = [string]$model.id if ([string]::IsNullOrWhiteSpace($modelId)) { continue } [void]$modelCandidates.Add($modelId) if ($modelId -notmatch '/') { [void]$modelCandidates.Add("$providerName/$modelId") } } } } catch { continue } } # Provider ids pi has actually fetched a catalog for live in models-store.json. $homePath = [Environment]::GetFolderPath('UserProfile') if (-not [string]::IsNullOrWhiteSpace($homePath)) { $storePath = Join-Path $homePath '.pi\agent\models-store.json' if (Test-Path -LiteralPath $storePath -PathType Leaf) { try { $store = Get-Content -LiteralPath $storePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop foreach ($storeProperty in @($store.PSObject.Properties)) { if ($storeProperty.Name -match '^[a-z0-9][a-z0-9-]*$') { [void]$providerNames.Add($storeProperty.Name) } } } catch { $null = $_ } } } $cache.CustomProviderNames = Get-PiUniqueStrings -Items @($providerNames.ToArray()) $cache.CustomModelCandidates = Get-PiUniqueStrings -Items @($modelCandidates.ToArray()) $cache.CustomModelDataLoadedAt = Get-Date } function Get-PiProviderNames { Update-PiCustomModelData $cache = Get-PiCompletionCache Get-PiUniqueStrings -Items @($cache.BuiltInProviders + $cache.CustomProviderNames) } function Get-PiModelCandidates { Update-PiCustomModelData $cache = Get-PiCompletionCache $candidates = New-Object System.Collections.Generic.List[string] foreach ($providerName in @(Get-PiProviderNames)) { [void]$candidates.Add("$providerName/*") [void]$candidates.Add("$providerName/") } [void]$candidates.Add('*sonnet*') [void]$candidates.Add('openai/gpt-4o') [void]$candidates.Add('sonnet:high') foreach ($customCandidate in @($cache.CustomModelCandidates)) { [void]$candidates.Add($customCandidate) } Get-PiUniqueStrings -Items @($candidates.ToArray()) } function Get-PiSessionFileSuggestions { $cache = Get-PiCompletionCache if (Test-PiCacheFresh -LoadedAt $cache.SessionFilesLoadedAt -TtlSeconds $cache.SessionFilesTtlSeconds) { return $cache.SessionFiles } $homePath = [Environment]::GetFolderPath('UserProfile') if ([string]::IsNullOrWhiteSpace($homePath)) { $cache.SessionFiles = @() $cache.SessionFilesLoadedAt = Get-Date return $cache.SessionFiles } $sessionRoot = Join-Path $homePath '.pi\agent\sessions' if (-not (Test-Path -LiteralPath $sessionRoot -PathType Container)) { $cache.SessionFiles = @() $cache.SessionFilesLoadedAt = Get-Date return $cache.SessionFiles } $files = @(Get-ChildItem -LiteralPath $sessionRoot -File -Recurse -Filter '*.jsonl' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 30 -ExpandProperty FullName) $cache.SessionFiles = Get-PiUniqueStrings -Items $files $cache.SessionFilesLoadedAt = Get-Date $cache.SessionFiles } function Get-PiKnownResourceRoots { param([string]$Kind) $homePath = [Environment]::GetFolderPath('UserProfile') $roots = New-Object System.Collections.Generic.List[string] switch ($Kind) { 'extension' { if (-not [string]::IsNullOrWhiteSpace($homePath)) { [void]$roots.Add((Join-Path $homePath '.pi\agent\extensions')) } [void]$roots.Add((Join-Path (Get-Location) '.pi\extensions')) } 'skill' { if (-not [string]::IsNullOrWhiteSpace($homePath)) { [void]$roots.Add((Join-Path $homePath '.pi\agent\skills')) } [void]$roots.Add((Join-Path (Get-Location) '.pi\skills')) } 'prompt-template' { if (-not [string]::IsNullOrWhiteSpace($homePath)) { [void]$roots.Add((Join-Path $homePath '.pi\agent\prompts')) } [void]$roots.Add((Join-Path (Get-Location) '.pi\prompts')) } 'theme' { if (-not [string]::IsNullOrWhiteSpace($homePath)) { [void]$roots.Add((Join-Path $homePath '.pi\agent\themes')) } [void]$roots.Add((Join-Path (Get-Location) '.pi\themes')) } } Get-PiUniqueStrings -Items $roots } function Get-PiKnownResourcePaths { param([string]$Kind) $cache = Get-PiCompletionCache if (Test-PiCacheFresh -LoadedAt $cache.ResourcePathsLoadedAt[$Kind] -TtlSeconds $cache.ResourcePathsTtlSeconds) { return $cache.ResourcePaths[$Kind] } $paths = New-Object System.Collections.Generic.List[string] foreach ($root in @(Get-PiKnownResourceRoots -Kind $Kind)) { if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue } foreach ($item in @(Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { [void]$paths.Add($item.FullName) } } $cache.ResourcePaths[$Kind] = Get-PiUniqueStrings -Items @($paths.ToArray()) $cache.ResourcePathsLoadedAt[$Kind] = Get-Date $cache.ResourcePaths[$Kind] } function Get-PiResourcePathCompletions { param( [string]$Kind, [string]$WordToComplete ) if (Test-PiPathLike -Value $WordToComplete) { Get-PiPathCompletions -PathPrefix $WordToComplete return } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($path in @(Get-PiKnownResourcePaths -Kind $Kind)) { if ($path -notlike "$WordToComplete*") { continue } if ($seen.Add($path)) { New-PiCompletionResult -CompletionText $path -ToolTip "$Kind path" } } if ([string]::IsNullOrEmpty($WordToComplete)) { foreach ($hint in @('.\', '..\')) { New-PiCompletionResult -CompletionText $hint -ToolTip "$Kind path" } } } function Get-PiCommaSeparatedCompletions { param( [string[]]$Candidates, [string]$WordToComplete, [string]$ToolTip ) $prefix = '' $segmentPrefix = $WordToComplete if ($WordToComplete -like '*,*') { $lastComma = $WordToComplete.LastIndexOf(',') $prefix = $WordToComplete.Substring(0, $lastComma + 1) $segmentPrefix = $WordToComplete.Substring($lastComma + 1) } foreach ($candidate in @(Get-PiUniqueStrings -Items $Candidates)) { if ($candidate -notlike "$segmentPrefix*") { continue } New-PiCompletionResult -CompletionText "$prefix$candidate" -ToolTip $ToolTip } } function Get-PiModelValueCompletions { param( [string]$WordToComplete, [string]$InlinePrefix ) $finalWord = if ($null -eq $WordToComplete) { '' } else { $WordToComplete } $thinkingLevels = (Get-PiCompletionCache).ThinkingLevels $addCompletion = { param([string]$value, [string]$toolTip) if ([string]::IsNullOrWhiteSpace($value)) { return } if ($value -notlike "$finalWord*") { return } $completionText = if ([string]::IsNullOrEmpty($InlinePrefix)) { $value } else { "$InlinePrefix$value" } $listText = if ([string]::IsNullOrEmpty($InlinePrefix)) { $value } else { $value } New-PiCompletionResult -CompletionText $completionText -ListItemText $listText -ToolTip $toolTip } if ($finalWord -like '*:*') { $colonIndex = $finalWord.LastIndexOf(':') $basePart = $finalWord.Substring(0, $colonIndex) $thinkingPrefix = $finalWord.Substring($colonIndex + 1) foreach ($level in @($thinkingLevels)) { if ($level -notlike "$thinkingPrefix*") { continue } & $addCompletion "${basePart}:$level" 'Model with thinking level' } return } foreach ($candidate in @(Get-PiModelCandidates)) { & $addCompletion $candidate 'Model pattern or ID' } if ([string]::IsNullOrEmpty($finalWord)) { & $addCompletion '<provider/model[:thinking]>' 'Model pattern or ID' } } function Get-PiValueCompletions { param( [string]$ValueKind, [string]$WordToComplete, [string]$ContextToken, [string]$InlinePrefix, [string]$Placeholder ) $cache = Get-PiCompletionCache $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] $addResult = { param( [string]$completionText, [string]$toolTip, [string]$resultType = 'ParameterValue', [string]$listItemText = $completionText ) if ([string]::IsNullOrWhiteSpace($completionText)) { return } if ($completionText -notlike "$WordToComplete*") { return } $finalCompletion = if ([string]::IsNullOrEmpty($InlinePrefix)) { $completionText } else { "$InlinePrefix$completionText" } $finalListItemText = if ([string]::IsNullOrEmpty($InlinePrefix)) { $listItemText } else { $completionText } [void]$results.Add( (New-PiCompletionResult -CompletionText $finalCompletion -ListItemText $finalListItemText -ResultType $resultType -ToolTip $toolTip) ) } switch ($ValueKind) { 'Provider' { foreach ($providerName in @(Get-PiProviderNames)) { & $addResult $providerName 'Provider name' } } 'Mode' { foreach ($mode in @($cache.OutputModes)) { & $addResult $mode 'Output mode' } } 'Thinking' { foreach ($level in @($cache.ThinkingLevels)) { & $addResult $level 'Thinking level' } } 'TuiMode' { foreach ($mode in @($cache.TuiModes)) { & $addResult $mode 'TUI mode' } } 'AuthCommand' { foreach ($authCommand in @($cache.AuthCommands)) { & $addResult $authCommand 'pi auth command' } } 'SessionName' { & $addResult '<name>' 'Session display name' } 'SessionId' { & $addResult '<id>' 'Project session ID' } 'ThemeName' { foreach ($themePath in @(Get-PiKnownResourcePaths -Kind 'theme')) { & $addResult ([System.IO.Path]::GetFileNameWithoutExtension($themePath)) 'Theme name' } & $addResult '<name[/name]>' 'Theme name (dark/light pair allowed)' } 'Placeholder' { $placeholderText = if ([string]::IsNullOrWhiteSpace($Placeholder)) { '<value>' } else { $Placeholder } & $addResult $placeholderText "Value for $ContextToken" } 'ToolList' { $prefix = '' $segmentPrefix = $WordToComplete $selectedTools = @() if ($WordToComplete -like '*,*') { $lastComma = $WordToComplete.LastIndexOf(',') $prefix = $WordToComplete.Substring(0, $lastComma + 1) $segmentPrefix = $WordToComplete.Substring($lastComma + 1) $selectedTools = @($prefix.TrimEnd(',').Split(',') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } foreach ($toolName in @($cache.BuiltInTools)) { if ($selectedTools -contains $toolName) { continue } if ($toolName -notlike "$segmentPrefix*") { continue } & $addResult "$prefix$toolName" 'Built-in tool' } } 'ModelPattern' { foreach ($item in @(Get-PiModelValueCompletions -WordToComplete $WordToComplete -InlinePrefix $InlinePrefix)) { [void]$results.Add($item) } } 'ModelPatternList' { $prefix = '' $segment = $WordToComplete $selectedPatterns = @() if ($WordToComplete -like '*,*') { $lastComma = $WordToComplete.LastIndexOf(',') $prefix = $WordToComplete.Substring(0, $lastComma + 1) $segment = $WordToComplete.Substring($lastComma + 1) $selectedPatterns = @($prefix.TrimEnd(',').Split(',') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } foreach ($item in @(Get-PiModelValueCompletions -WordToComplete $segment)) { if ($selectedPatterns -contains $item.CompletionText) { continue } $completionText = "$prefix$($item.CompletionText)" if (-not [string]::IsNullOrEmpty($InlinePrefix)) { $completionText = "$InlinePrefix$completionText" } [void]$results.Add( (New-PiCompletionResult -CompletionText $completionText -ListItemText $item.ListItemText -ToolTip $item.ToolTip) ) } } 'ApiKey' { & $addResult '<api-key>' 'API key value' } 'SystemPrompt' { & $addResult '<text>' 'System prompt text' } 'TextOrFile' { if (Test-PiPathLike -Value $WordToComplete) { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } } & $addResult '<text-or-file>' 'Text or a file path' } 'ModelSearch' { & $addResult '<search>' 'Optional model search text' } 'DirectoryPath' { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -DirectoriesOnly -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } if ([string]::IsNullOrEmpty($WordToComplete)) { & $addResult '.\' 'Directory path' & $addResult '..\' 'Directory path' } } 'FilePath' { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } if ([string]::IsNullOrEmpty($WordToComplete)) { & $addResult '.\' 'File path' & $addResult '..\' 'File path' & $addResult '<path>' 'File path' } } 'ExtensionPath' { foreach ($item in @(Get-PiResourcePathCompletions -Kind 'extension' -WordToComplete $WordToComplete)) { [void]$results.Add($item) } } 'SkillPath' { foreach ($item in @(Get-PiResourcePathCompletions -Kind 'skill' -WordToComplete $WordToComplete)) { [void]$results.Add($item) } } 'PromptTemplatePath' { foreach ($item in @(Get-PiResourcePathCompletions -Kind 'prompt-template' -WordToComplete $WordToComplete)) { [void]$results.Add($item) } } 'ThemePath' { foreach ($item in @(Get-PiResourcePathCompletions -Kind 'theme' -WordToComplete $WordToComplete)) { [void]$results.Add($item) } } 'ExportInputPath' { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } foreach ($sessionFile in @(Get-PiSessionFileSuggestions)) { & $addResult $sessionFile 'Session file to export' } & $addResult '<session.jsonl>' 'Session file to export' } 'ExportOutputPath' { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } if ([string]::IsNullOrEmpty($WordToComplete) -or 'output.html' -like ([System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*')) { & $addResult 'output.html' 'Output HTML file' } } 'SessionPathOrId' { if (Test-PiPathLike -Value $WordToComplete) { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } } else { foreach ($sessionFile in @(Get-PiSessionFileSuggestions)) { & $addResult $sessionFile 'Session file' } & $addResult '<session-path-or-id>' 'Session path or partial UUID' } } 'PackageSource' { $sourcePrefixes = @('npm:', 'git:', 'https://', 'ssh://git@github.com/', '.\', '..\') foreach ($source in @($sourcePrefixes)) { & $addResult $source 'Package source' } if (Test-PiPathLike -Value $WordToComplete) { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } } & $addResult '<source>' 'Extension source (npm, git, https, ssh, or local path)' } 'InstalledPackageSource' { foreach ($source in @('npm:', 'git:', 'https://', 'ssh://git@github.com/', '.\', '..\')) { & $addResult $source 'Package source' } foreach ($source in @(Get-PiInstalledPackageSources)) { & $addResult $source 'Installed package source' } if (Test-PiPathLike -Value $WordToComplete) { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } } & $addResult '<source>' 'Installed package source' } 'UpdateTarget' { foreach ($target in @('self', 'pi')) { & $addResult $target 'Update target' } foreach ($source in @(Get-PiInstalledPackageSources)) { & $addResult $source 'Installed package source' } foreach ($source in @('npm:', 'git:', 'https://', 'ssh://git@github.com/', '.\', '..\')) { & $addResult $source 'Package source' } if (Test-PiPathLike -Value $WordToComplete) { foreach ($item in @(Get-PiPathCompletions -PathPrefix $WordToComplete -CompletionPrefix $InlinePrefix)) { [void]$results.Add($item) } } & $addResult '<source>' 'Installed package source or self target' } 'MessageFile' { foreach ($item in @(Get-PiAtFileCompletions -WordToComplete $WordToComplete)) { [void]$results.Add($item) } } 'Message' { & $addResult '<message>' 'Prompt text' } default { & $addResult '<value>' "Value for $ContextToken" } } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($item in @($results)) { if ($seen.Add($item.CompletionText)) { $item } } } function Get-PiOptionCompletions { param( [object[]]$Options, [string]$WordToComplete ) foreach ($option in @($Options)) { $completionText = $option.CompletionText if ($completionText -notlike "$WordToComplete*") { continue } $toolTip = if ($option.ValueKind) { "$($option.Token): $($option.Description)" } else { $option.Description } New-PiCompletionResult -CompletionText $completionText -ResultType 'ParameterName' -ToolTip $toolTip } } function Complete-Pi { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $cache = Get-PiCompletionCache $globalOptions = @(Get-PiGlobalOptions) $WordToComplete = Get-PiCurrentWord -WordToComplete $WordToComplete -CommandAst $CommandAst -CursorPosition $CursorPosition $tokens = @(Get-PiProcessedTokens -CommandAst $CommandAst -CursorPosition $CursorPosition) $commandSpec = $null $positionalsConsumed = 0 $expectingValue = $null $rootMessageMode = $false $exportInputConsumed = $false $exportOutputConsumed = $false $selectedUpdateTarget = $false foreach ($token in @($tokens)) { if ($expectingValue) { $consumeAsValue = $true if ($expectingValue.OptionalValue) { if ($token.StartsWith('-')) { $consumeAsValue = $false } elseif (-not $commandSpec -and -not $rootMessageMode -and (Get-PiCommandSpec -CommandName $token)) { $consumeAsValue = $false } } if ($consumeAsValue) { if ($expectingValue.ValueKind -eq 'ExportInputPath' -and -not $commandSpec) { $exportInputConsumed = $true } if ($commandSpec -and $commandSpec.Name -eq 'update' -and $expectingValue.Token -eq '--extension') { $selectedUpdateTarget = $true } $expectingValue = $null continue } $expectingValue = $null } if ($rootMessageMode) { continue } if (-not $commandSpec) { if ($token -eq '--') { $rootMessageMode = $true continue } if ($token.StartsWith('-')) { $globalOption = Find-PiOptionSpec -Token $token -Options $globalOptions if ($globalOption) { if ($globalOption.ValueKind) { if ($token.Contains('=')) { $equalsIndex = $token.IndexOf('=') $inlineValue = $token.Substring($equalsIndex + 1) if (-not [string]::IsNullOrWhiteSpace($inlineValue) -and $globalOption.ValueKind -eq 'ExportInputPath') { $exportInputConsumed = $true } } else { $expectingValue = $globalOption } } continue } continue } if ((-not $exportInputConsumed) -and (Get-PiCommandSpec -CommandName $token)) { $commandSpec = Get-PiCommandSpec -CommandName $token continue } if ($exportInputConsumed -and -not $exportOutputConsumed) { $exportOutputConsumed = $true continue } if ($token.StartsWith('@')) { continue } $rootMessageMode = $true continue } if ($token.StartsWith('-')) { $commandOption = Find-PiOptionSpec -Token $token -Options $commandSpec.Options if ($commandOption) { if ($commandSpec.Name -eq 'update' -and $commandOption.Token -in @('--self', '--extensions')) { $selectedUpdateTarget = $true } if ($commandOption.ValueKind) { if ($token.Contains('=')) { $equalsIndex = $token.IndexOf('=') $inlineValue = $token.Substring($equalsIndex + 1) if ($commandSpec.Name -eq 'update' -and $commandOption.Token -eq '--extension' -and -not [string]::IsNullOrWhiteSpace($inlineValue)) { $selectedUpdateTarget = $true } } else { $expectingValue = $commandOption } } elseif ($commandSpec.Name -eq 'update' -and $commandOption.Token -eq '--extension') { $selectedUpdateTarget = $true } continue } continue } $positionalsConsumed++ if ($commandSpec.Name -eq 'update') { $selectedUpdateTarget = $true } } if ($expectingValue -and $expectingValue.OptionalValue) { if ($WordToComplete -like '-*') { $expectingValue = $null } } if ([string]::IsNullOrEmpty($WordToComplete) -and $tokens.Count -gt 0 -and $tokens[-1].Contains('=')) { $options = if ($commandSpec) { $commandSpec.Options } else { $globalOptions } $inlineEmptyOption = Find-PiOptionSpec -Token $tokens[-1] -Options $options if ($inlineEmptyOption -and $inlineEmptyOption.ValueKind) { $equalsIndex = $tokens[-1].IndexOf('=') $flagPart = $tokens[-1].Substring(0, $equalsIndex) $valuePrefix = $tokens[-1].Substring($equalsIndex + 1) if ([string]::IsNullOrEmpty($valuePrefix)) { Get-PiValueCompletions -ValueKind $inlineEmptyOption.ValueKind -WordToComplete $valuePrefix -ContextToken $inlineEmptyOption.Token -InlinePrefix "$flagPart=" -Placeholder $inlineEmptyOption.Placeholder return } } } if ($WordToComplete -like '*=*') { $equalsIndex = $WordToComplete.IndexOf('=') $flagPart = $WordToComplete.Substring(0, $equalsIndex) $valuePrefix = $WordToComplete.Substring($equalsIndex + 1) $options = if ($commandSpec) { $commandSpec.Options } else { $globalOptions } $inlineOption = Find-PiOptionSpec -Token $flagPart -Options $options if ($inlineOption -and $inlineOption.ValueKind) { Get-PiValueCompletions -ValueKind $inlineOption.ValueKind -WordToComplete $valuePrefix -ContextToken $inlineOption.Token -InlinePrefix "$flagPart=" -Placeholder $inlineOption.Placeholder return } } if ($expectingValue) { Get-PiValueCompletions -ValueKind $expectingValue.ValueKind -WordToComplete $WordToComplete -ContextToken $expectingValue.Token -Placeholder $expectingValue.Placeholder return } if ($WordToComplete -like '-*') { if ($commandSpec) { Get-PiOptionCompletions -Options $commandSpec.Options -WordToComplete $WordToComplete } elseif (-not $rootMessageMode) { Get-PiOptionCompletions -Options $globalOptions -WordToComplete $WordToComplete } return } if ($rootMessageMode) { if ($WordToComplete.StartsWith('@')) { Get-PiAtFileCompletions -WordToComplete $WordToComplete return } if ([string]::IsNullOrEmpty($WordToComplete)) { New-PiCompletionResult -CompletionText '<message>' -ToolTip 'Prompt text' } return } if (-not $commandSpec) { if ($exportInputConsumed -and -not $exportOutputConsumed) { Get-PiValueCompletions -ValueKind 'ExportOutputPath' -WordToComplete $WordToComplete -ContextToken '--export' return } if ($WordToComplete.StartsWith('@')) { Get-PiAtFileCompletions -WordToComplete $WordToComplete return } $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] foreach ($command in @(Get-PiRootCommands)) { if ($command.Name -like ([System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*')) { [void]$results.Add( (New-PiCompletionResult -CompletionText $command.Name -ToolTip $command.Description) ) } } if ([string]::IsNullOrEmpty($WordToComplete)) { foreach ($option in @(Get-PiOptionCompletions -Options $globalOptions -WordToComplete '')) { [void]$results.Add($option) } [void]$results.Add((New-PiCompletionResult -CompletionText '@' -ToolTip 'Prefix a file path with @ to include it in the message')) } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($item in @($results)) { if ($seen.Add($item.CompletionText)) { $item } } return } $valueKind = if ($commandSpec.Name -eq 'update' -and $selectedUpdateTarget -and $positionalsConsumed -eq 0) { $null } elseif ($positionalsConsumed -lt $commandSpec.Positionals.Count) { $commandSpec.Positionals[$positionalsConsumed] } else { $null } $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] if ($valueKind) { foreach ($item in @(Get-PiValueCompletions -ValueKind $valueKind -WordToComplete $WordToComplete -ContextToken $commandSpec.Name)) { [void]$results.Add($item) } } if ([string]::IsNullOrEmpty($WordToComplete)) { foreach ($option in @(Get-PiOptionCompletions -Options $commandSpec.Options -WordToComplete '')) { [void]$results.Add($option) } } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($item in @($results)) { if ($seen.Add($item.CompletionText)) { $item } } } Register-ArgumentCompleter -Native -CommandName @('pi', 'pi.cmd', 'pi.ps1') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Pi -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } |