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 - the `pi mcp` tree (add/remove/list/login/logout) with per-subcommand options - 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 - provider/model discovery from `models-store.json`, `models.json` and the settings defaults - installed package sources from `settings.json`, MCP server names from `mcp.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.' ) # `pi mcp` takes no option before its sub-command and accepts each option # only on the sub-commands its parser lists (pi 1.0.4 `pi mcp --help`). $mcpHelpOptions = @( New-PiOptionSpec -Tokens @('--help', '-h') -Description 'Show mcp help.' ) $mcpLocalOptions = @( New-PiOptionSpec -Tokens @('-l', '--local') -Description 'Use .pi/mcp.json in the current project instead of the global file.' ) $mcpAddOptions = @( New-PiOptionSpec -Tokens @('--url') -Description 'Streamable HTTP server URL (instead of a command).' -ValueKind 'Placeholder' -Placeholder '<url>' New-PiOptionSpec -Tokens @('--env') -Description 'Environment variable for a stdio server (repeatable).' -ValueKind 'Placeholder' -Placeholder '<KEY=VALUE>' New-PiOptionSpec -Tokens @('--cwd') -Description 'Working directory for a stdio server.' -ValueKind 'DirectoryPath' New-PiOptionSpec -Tokens @('--header') -Description 'HTTP header (repeatable).' -ValueKind 'Placeholder' -Placeholder '<KEY=VALUE>' New-PiOptionSpec -Tokens @('--bearer-token-env-var') -Description 'Send "Authorization: Bearer ${NAME}".' -ValueKind 'Placeholder' -Placeholder '<NAME>' New-PiOptionSpec -Tokens @('--oauth-client-id') -Description 'Pre-registered OAuth client id.' -ValueKind 'Placeholder' -Placeholder '<id>' New-PiOptionSpec -Tokens @('--oauth-client-secret') -Description 'OAuth client secret (may be ${NAME} or !command).' -ValueKind 'Placeholder' -Placeholder '<secret>' New-PiOptionSpec -Tokens @('--oauth-callback-port') -Description 'Fixed OAuth callback port.' -ValueKind 'Placeholder' -Placeholder '<port>' New-PiOptionSpec -Tokens @('--oauth-client-name') -Description 'Client name sent when registering with the OAuth server.' -ValueKind 'Placeholder' -Placeholder '<name>' New-PiOptionSpec -Tokens @('--exposure') -Description 'codemode (default), deferred, direct, or hidden.' -ValueKind 'McpExposure' New-PiOptionSpec -Tokens @('--description') -Description 'What the server offers, shown in the system prompt.' -ValueKind 'Placeholder' -Placeholder '<text>' ) $mcpCommandSpecs = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) $mcpCommandSpecs['add'] = New-PiCommandSpec -Name 'mcp' -Description 'Add or replace a server in mcp.json.' -Positionals @('McpCommand', 'McpNewServer') -Options @($mcpLocalOptions + $mcpAddOptions + $mcpHelpOptions) $mcpCommandSpecs['remove'] = New-PiCommandSpec -Name 'mcp' -Description 'Remove a server from mcp.json.' -Positionals @('McpCommand', 'McpServer') -Options @($mcpLocalOptions + $mcpHelpOptions) $mcpCommandSpecs['list'] = New-PiCommandSpec -Name 'mcp' -Description 'Show state, tools, and errors.' -Positionals @('McpCommand') -Options @( @(New-PiOptionSpec -Tokens @('--json') -Description 'Print the list as JSON.') + $mcpHelpOptions ) $mcpCommandSpecs['login'] = New-PiCommandSpec -Name 'mcp' -Description 'Sign in through the browser.' -Positionals @('McpCommand', 'McpServer') -Options @( @(New-PiOptionSpec -Tokens @('--timeout') -Description 'How long login waits for the browser (default: 300).' -ValueKind 'Placeholder' -Placeholder '<seconds>') + $mcpHelpOptions ) $mcpCommandSpecs['logout'] = New-PiCommandSpec -Name 'mcp' -Description 'Delete the stored OAuth credentials.' -Positionals @('McpCommand', 'McpServer') -Options $mcpHelpOptions $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.' ) New-PiCommandSpec -Name 'mcp' -Description 'Configure and check MCP servers.' -Positionals @('McpCommand') -Options $mcpHelpOptions ) $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') McpCommands = @('add', 'remove', 'list', 'login', 'logout') McpCommandSpecs = $mcpCommandSpecs McpExposureModes = @('codemode', 'deferred', 'direct', 'hidden') ExecutablePath = $null ExecutablePathProbed = $false CustomModelDataLoadedAt = [datetime]::MinValue CustomModelDataTtlSeconds = 120 CustomModelDataLocation = '' CustomProviderNames = @() CustomModelCandidates = @() DefaultModelCandidates = @() HelpVersionStamp = '' RootHelpLoadedAt = [datetime]::MinValue RootHelpTtlSeconds = 900 CommandHelpLoadedAt = @{ install = [datetime]::MinValue remove = [datetime]::MinValue uninstall = [datetime]::MinValue update = [datetime]::MinValue list = [datetime]::MinValue } CommandHelpTtlSeconds = 900 InstalledPackageSourcesLoadedAt = [datetime]::MinValue InstalledPackageSourcesTtlSeconds = 60 InstalledPackageSourcesLocation = '' InstalledPackageSources = @() McpServerNamesLoadedAt = [datetime]::MinValue McpServerNamesTtlSeconds = 60 McpServerNamesLocation = '' McpServerNames = @() 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 # The application shims start node directly; pi.ps1 would add a pwsh start-up # to every capture, so it is only the last resort. foreach ($candidate in @( @{ Name = 'pi.cmd'; Type = 'Application' }, @{ Name = 'pi'; Type = 'Application' }, @{ Name = 'pi.ps1'; Type = 'ExternalScript' } )) { $command = Get-Command -Name $candidate.Name -CommandType $candidate.Type -ErrorAction Ignore | Select-Object -First 1 if ($command) { $cache.ExecutablePath = if ($command.Source) { $command.Source } else { $command.Name } break } } $cache.ExecutablePath } function Get-PiAgentDirectory { $agentDirectory = $env:PI_CODING_AGENT_DIR $homePath = [Environment]::GetFolderPath('UserProfile') if (-not [string]::IsNullOrWhiteSpace($agentDirectory)) { if ($agentDirectory -eq '~' -or $agentDirectory.StartsWith('~/') -or $agentDirectory.StartsWith('~\')) { $agentDirectory = $homePath + $agentDirectory.Substring(1) } return $agentDirectory } if ([string]::IsNullOrWhiteSpace($homePath)) { return '' } [System.IO.Path]::Combine($homePath, '.pi', 'agent') } function Sync-PiHelpVersionStamp { # Help output changes when pi is upgraded (managed installs rewrite # install/current-version, npm installs rewrite the shim) or when a package # with extension CLI flags is added to settings.json. $cache = Get-PiCompletionCache $stampPaths = New-Object System.Collections.Generic.List[string] $agentDirectory = Get-PiAgentDirectory if (-not [string]::IsNullOrWhiteSpace($agentDirectory)) { [void]$stampPaths.Add([System.IO.Path]::Combine($agentDirectory, 'install', 'current-version')) [void]$stampPaths.Add([System.IO.Path]::Combine($agentDirectory, 'settings.json')) } $executablePath = Resolve-PiExecutablePath if (-not [string]::IsNullOrWhiteSpace($executablePath)) { [void]$stampPaths.Add($executablePath) } $stamp = [string]::Join('|', @( foreach ($stampPath in $stampPaths) { "$stampPath=$([System.IO.File]::GetLastWriteTimeUtc($stampPath).Ticks)" } )) if ($stamp -ceq $cache.HelpVersionStamp) { return } $cache.HelpVersionStamp = $stamp $cache.RootHelpLoadedAt = [datetime]::MinValue foreach ($commandName in @($cache.CommandHelpLoadedAt.Keys)) { $cache.CommandHelpLoadedAt[$commandName] = [datetime]::MinValue } } 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(5000)) { 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 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 { Sync-PiHelpVersionStamp $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 } Sync-PiHelpVersionStamp 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-PiSettingsPath { # Global settings first, then the project file that overrides it. $paths = New-Object System.Collections.Generic.List[string] $agentDirectory = Get-PiAgentDirectory if (-not [string]::IsNullOrWhiteSpace($agentDirectory)) { [void]$paths.Add([System.IO.Path]::Combine($agentDirectory, 'settings.json')) } [void]$paths.Add((Join-Path (Get-Location) '.pi\settings.json')) @($paths.ToArray()) } function Read-PiJsonFile { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } # Returns the root object as a case-sensitive dictionary (pi's JSON.parse keeps # 'a' and 'A' apart), or $null. Nothing here may throw: a caught error is still # recorded in $Error, so Test-Json (which records nothing) rejects a malformed # file before ConvertFrom-Json, and -AsHashtable accepts every document that # passes it, keys differing only in case included. Comments and trailing commas # are accepted in every file (pi strips comments from models.json too). $content = Get-Content -LiteralPath $Path -Raw -ErrorAction Ignore if ([string]::IsNullOrWhiteSpace($content) -or -not (Test-Json -Json $content -Options IgnoreComments, AllowTrailingCommas -ErrorAction Ignore)) { return $null } $root = ConvertFrom-Json -InputObject $content -AsHashtable if ($root -is [System.Collections.IDictionary]) { return $root } $null } function Get-PiInstalledPackageSources { # pi's package list is the packages[] array of the global and project # settings.json (entries are a source string or an object with .source). $cache = Get-PiCompletionCache $location = (Get-Location).ProviderPath if ($cache.InstalledPackageSourcesLocation -ceq $location -and (Test-PiCacheFresh -LoadedAt $cache.InstalledPackageSourcesLoadedAt -TtlSeconds $cache.InstalledPackageSourcesTtlSeconds)) { return $cache.InstalledPackageSources } $sources = New-Object System.Collections.Generic.List[string] foreach ($settingsPath in @(Get-PiSettingsPath)) { $settings = Read-PiJsonFile -Path $settingsPath if ($null -eq $settings -or $null -eq $settings['packages']) { continue } foreach ($package in @($settings['packages'])) { $source = if ($package -is [string]) { $package } elseif ($package -is [System.Collections.IDictionary] -and $null -ne $package['source']) { [string]$package['source'] } else { $null } if (-not [string]::IsNullOrWhiteSpace($source)) { [void]$sources.Add($source) } } } $cache.InstalledPackageSources = Get-PiUniqueStrings -Items @($sources.ToArray()) $cache.InstalledPackageSourcesLocation = $location $cache.InstalledPackageSourcesLoadedAt = Get-Date $cache.InstalledPackageSources } function Get-PiMcpServerName { # Server names only (never the configs, which can carry headers and # secrets) from the global and project mcp.json `mcpServers` objects. $cache = Get-PiCompletionCache $location = (Get-Location).ProviderPath if ($cache.McpServerNamesLocation -ceq $location -and (Test-PiCacheFresh -LoadedAt $cache.McpServerNamesLoadedAt -TtlSeconds $cache.McpServerNamesTtlSeconds)) { return $cache.McpServerNames } $paths = New-Object System.Collections.Generic.List[string] $agentDirectory = Get-PiAgentDirectory if (-not [string]::IsNullOrWhiteSpace($agentDirectory)) { [void]$paths.Add([System.IO.Path]::Combine($agentDirectory, 'mcp.json')) } [void]$paths.Add((Join-Path (Get-Location) '.pi\mcp.json')) $names = New-Object System.Collections.Generic.List[string] foreach ($path in $paths) { $config = Read-PiJsonFile -Path $path if ($null -eq $config -or $config['mcpServers'] -isnot [System.Collections.IDictionary]) { continue } foreach ($server in $config['mcpServers'].GetEnumerator()) { [void]$names.Add([string]$server.Key) } } $cache.McpServerNames = Get-PiUniqueStrings -Items @($names.ToArray()) $cache.McpServerNamesLocation = $location $cache.McpServerNamesLoadedAt = Get-Date $cache.McpServerNames } 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]) { # A quoted (possibly unterminated) word is matched without its quotes; # PowerShell hands the completer the quoted text. if ($element.StringConstantType -ne [System.Management.Automation.Language.StringConstantType]::BareWord) { return $element.Value } 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-PiQuoteCharacter { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # The quote the user opened the word under the cursor with, if any. 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.Text.StartsWith("'") -or $extent.Text.StartsWith('"')) { return $extent.Text.Substring(0, 1) } return '' } '' } function ConvertTo-PiQuotedValue { param( [string]$Value, [string]$QuoteCharacter ) if ([string]::IsNullOrEmpty($QuoteCharacter) -and $Value -notmatch '[\s{}();,|&<>''"`$]|^[@#]') { return $Value } if ($QuoteCharacter -eq '"') { return '"' + ($Value -replace '([`"$])', '`$1') + '"' } "'" + $Value.Replace("'", "''") + "'" } 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] $agentDirectory = Get-PiAgentDirectory if (-not [string]::IsNullOrWhiteSpace($agentDirectory)) { [void]$paths.Add([System.IO.Path]::Combine($agentDirectory, 'models.json')) [void]$paths.Add([System.IO.Path]::Combine($agentDirectory, '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 Add-PiModelEntry { param( [string]$ProviderName, [object]$ProviderValue, [System.Collections.Generic.List[string]]$ModelCandidates ) if ($ProviderValue -isnot [System.Collections.IDictionary] -or $null -eq $ProviderValue['models']) { return } foreach ($model in @($ProviderValue['models'])) { if ($model -isnot [System.Collections.IDictionary] -or $null -eq $model['id']) { continue } $modelId = [string]$model['id'] if ([string]::IsNullOrWhiteSpace($modelId)) { continue } [void]$ModelCandidates.Add($modelId) if ($modelId -notmatch '/') { [void]$ModelCandidates.Add("$ProviderName/$modelId") } } } function Update-PiCustomModelData { $cache = Get-PiCompletionCache $location = (Get-Location).ProviderPath if ($cache.CustomModelDataLocation -ceq $location -and (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)) { $config = Read-PiJsonFile -Path $path if ($null -eq $config -or $config['providers'] -isnot [System.Collections.IDictionary]) { continue } foreach ($provider in $config['providers'].GetEnumerator()) { $providerName = [string]$provider.Key if ([string]::IsNullOrWhiteSpace($providerName)) { continue } [void]$providerNames.Add($providerName) Add-PiModelEntry -ProviderName $providerName -ProviderValue $provider.Value -ModelCandidates $modelCandidates } } # models-store.json holds the catalog pi fetched per provider, in the same # provider.models[].id shape as models.json. $agentDirectory = Get-PiAgentDirectory if (-not [string]::IsNullOrWhiteSpace($agentDirectory)) { $store = Read-PiJsonFile -Path ([System.IO.Path]::Combine($agentDirectory, 'models-store.json')) if ($null -ne $store) { foreach ($storeEntry in $store.GetEnumerator()) { $providerName = [string]$storeEntry.Key if ($providerName -match '^[a-z0-9][a-z0-9-]*$') { [void]$providerNames.Add($providerName) Add-PiModelEntry -ProviderName $providerName -ProviderValue $storeEntry.Value -ModelCandidates $modelCandidates } } } } # The configured default model leads the list (project settings override global). $defaultProvider = $null $defaultModel = $null foreach ($settingsPath in @(Get-PiSettingsPath)) { $settings = Read-PiJsonFile -Path $settingsPath if ($null -eq $settings) { continue } if ($settings['defaultProvider'] -is [string]) { $defaultProvider = $settings['defaultProvider'] } if ($settings['defaultModel'] -is [string]) { $defaultModel = $settings['defaultModel'] } } $defaultCandidates = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($defaultModel)) { [void]$defaultCandidates.Add($defaultModel) if (-not [string]::IsNullOrWhiteSpace($defaultProvider) -and $defaultModel -notmatch '/') { [void]$defaultCandidates.Add("$defaultProvider/$defaultModel") } } $cache.CustomProviderNames = Get-PiUniqueStrings -Items @($providerNames.ToArray()) $cache.CustomModelCandidates = Get-PiUniqueStrings -Items @($modelCandidates.ToArray()) $cache.DefaultModelCandidates = @($defaultCandidates.ToArray()) $cache.CustomModelDataLocation = $location $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 ($defaultCandidate in @($cache.DefaultModelCandidates)) { [void]$candidates.Add($defaultCandidate) } 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, [string]$QuoteCharacter ) $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, [string]$emittedText = $completionText ) if ([string]::IsNullOrWhiteSpace($completionText)) { return } if ($completionText -notlike "$WordToComplete*") { return } $finalCompletion = if ([string]::IsNullOrEmpty($InlinePrefix)) { $emittedText } else { "$InlinePrefix$emittedText" } $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' } } 'McpCommand' { foreach ($mcpCommand in @($cache.McpCommands)) { & $addResult $mcpCommand $cache.McpCommandSpecs[$mcpCommand].Description } } 'McpServer' { foreach ($serverName in @(Get-PiMcpServerName)) { & $addResult $serverName 'Configured MCP server' -emittedText (ConvertTo-PiQuotedValue -Value $serverName -QuoteCharacter $QuoteCharacter) } & $addResult '<server>' 'MCP server name' } 'McpNewServer' { & $addResult '<server>' 'Name of the MCP server to add or replace' } 'McpExposure' { foreach ($exposureMode in @($cache.McpExposureModes)) { & $addResult $exposureMode 'MCP tool exposure' } } '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' -emittedText (ConvertTo-PiQuotedValue -Value $source -QuoteCharacter $QuoteCharacter) } 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' -emittedText (ConvertTo-PiQuotedValue -Value $source -QuoteCharacter $QuoteCharacter) } 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) $quoteCharacter = Get-PiQuoteCharacter -CommandAst $CommandAst -CursorPosition $CursorPosition $WordToComplete = Get-PiCurrentWord -WordToComplete $WordToComplete -CommandAst $CommandAst -CursorPosition $CursorPosition $tokens = @(Get-PiProcessedTokens -CommandAst $CommandAst -CursorPosition $CursorPosition) $commandSpec = $null $positionalsConsumed = 0 $expectingValue = $null $rootMessageMode = $false $mcpCommandArguments = $false $mcpSubcommand = '' $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 -or $mcpCommandArguments) { 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 } # `pi mcp add <server> -- <command> [args...]`: everything after `--` # belongs to the server command. if ($mcpSubcommand -ceq 'add' -and $token -eq '--') { $mcpCommandArguments = $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 ($commandSpec.Name -eq 'mcp') { if ($positionalsConsumed -eq 1 -and $cache.McpCommandSpecs.ContainsKey($token)) { # Each mcp sub-command has its own options and operands. $mcpSubcommand = $token $commandSpec = $cache.McpCommandSpecs[$token] } elseif ($mcpSubcommand -ceq 'add' -and $positionalsConsumed -ge 3) { # pi stops parsing options once the server name and the command are seen. $mcpCommandArguments = $true } } } if ($mcpCommandArguments) { return } if ($expectingValue -and $expectingValue.OptionalValue) { if ($WordToComplete -like '-*') { $expectingValue = $null } } # pi mcp's own option parser has no --option=value form. $attachedValues = -not ($commandSpec -and $commandSpec.Name -eq 'mcp') if ($attachedValues -and [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 -QuoteCharacter $quoteCharacter return } } } if ($attachedValues -and $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 -QuoteCharacter $quoteCharacter return } } if ($expectingValue) { Get-PiValueCompletions -ValueKind $expectingValue.ValueKind -WordToComplete $WordToComplete -ContextToken $expectingValue.Token -Placeholder $expectingValue.Placeholder -QuoteCharacter $quoteCharacter 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 -QuoteCharacter $quoteCharacter)) { [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 } |