completers/codex_completer/codex_completer.ps1
|
<# .SYNOPSIS Registers PowerShell tab completion for the local `codex` CLI. .DESCRIPTION This completer is a thin, importer-safe wrapper around the PowerShell completion script emitted by `codex completion powershell`. The generated completion script is loaded lazily on first completion request, its built-in self-registration is rewritten into an invokable script block, and that script block is cached for the rest of the session. This script registers completion for `codex`, `codex.cmd`, and `codex.ps1` because all three launcher names can be relevant on Windows. #> Set-StrictMode -Version Latest function Get-CodexCompletionExecutablePath { $pathProbeComplete = Get-Variable -Name CodexCompletionExecutablePathProbed -Scope Script -ErrorAction Ignore if ($null -ne $pathProbeComplete -and $pathProbeComplete.Value) { $cachedPath = Get-Variable -Name CodexCompletionExecutablePath -Scope Script -ErrorAction Ignore if ($null -ne $cachedPath) { return $cachedPath.Value } return $null } $script:CodexCompletionExecutablePathProbed = $true $script:CodexCompletionExecutablePath = $null foreach ($candidate in @( @{ Name = 'codex.cmd'; CommandType = 'Application' } @{ Name = 'codex'; CommandType = 'Application' } @{ Name = 'codex.ps1'; CommandType = 'ExternalScript' } )) { $command = Get-Command -Name $candidate.Name -CommandType $candidate.CommandType -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $command) { continue } $script:CodexCompletionExecutablePath = if ($command.Source) { $command.Source } else { $command.Path } break } $script:CodexCompletionExecutablePath } function Get-CodexGeneratedCompletionScript { $codexExecutablePath = Get-CodexCompletionExecutablePath if ([string]::IsNullOrWhiteSpace($codexExecutablePath)) { return } try { $completionScript = & $codexExecutablePath completion powershell 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String } catch { return } if ([string]::IsNullOrWhiteSpace($completionScript)) { return } $completionScript } function ConvertTo-CodexCompletionInvokerSource { param([string]$CompletionScript) if ([string]::IsNullOrWhiteSpace($CompletionScript)) { return } $registerPattern = '(?m)^\s*Register-ArgumentCompleter\s+-Native\s+-CommandName\s+[''"]codex[''"]\s+-ScriptBlock\s+\{\s*$' $rewrittenScript = [regex]::Replace( $CompletionScript, $registerPattern, { param($match) '$__codexCompleterBlock = {' }, 1 ) if ($rewrittenScript -eq $CompletionScript) { return } @" $rewrittenScript & `$__codexCompleterBlock @args "@ } function Get-CodexCompletionInvoker { $cachedInvoker = Get-Variable -Name CodexCompletionInvoker -Scope Script -ErrorAction Ignore if ($null -ne $cachedInvoker) { return $cachedInvoker.Value } $completionScript = Get-CodexGeneratedCompletionScript if ([string]::IsNullOrWhiteSpace($completionScript)) { return } $completionInvokerSource = ConvertTo-CodexCompletionInvokerSource -CompletionScript $completionScript if ([string]::IsNullOrWhiteSpace($completionInvokerSource)) { return } try { $script:CodexCompletionInvoker = [scriptblock]::Create($completionInvokerSource) return $script:CodexCompletionInvoker } catch { return } } function Get-CodexHomePath { if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { return $env:CODEX_HOME } Join-Path -Path $HOME -ChildPath '.codex' } function Get-CodexStateFileValue { <# .SYNOPSIS Parses a file under $CODEX_HOME once and reuses the result until the file's size or write time changes. #> param( [string]$Kind, [string]$Path, [scriptblock]$Parser ) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Ignore if ($null -eq $item -or $item.PSIsContainer) { return } if ($null -eq (Get-Variable -Name CodexStateFileCache -Scope Script -ErrorAction Ignore)) { $script:CodexStateFileCache = @{} } $cacheKey = $Kind + '|' + $item.FullName $stamp = '{0}|{1}' -f $item.LastWriteTimeUtc.Ticks, $item.Length $entry = $script:CodexStateFileCache[$cacheKey] if ($null -ne $entry -and $entry.Stamp -eq $stamp) { return $entry.Values } $values = @(& $Parser $item.FullName) $script:CodexStateFileCache[$cacheKey] = @{ Stamp = $stamp; Values = $values } $values } function ConvertFrom-CodexJson { param([string]$Json) # A malformed or half-written state file is an expected miss while completing, # not a fault to leave behind in $Error (a caught exception is still recorded). $errorCountBefore = $Error.Count try { $Json | ConvertFrom-Json -AsHashtable -ErrorAction Stop } catch { Write-Debug ('codex completer: cannot parse JSON state: ' + $_.Exception.Message) } finally { while ($Error.Count -gt $errorCountBefore) { $Error.RemoveAt(0) } } } function Get-CodexSessionValue { $indexPath = Join-Path -Path (Get-CodexHomePath) -ChildPath 'session_index.jsonl' Get-CodexStateFileValue -Kind 'session' -Path $indexPath -Parser { param($Path) $lines = @(Get-Content -LiteralPath $Path -Tail 500 -ErrorAction Ignore | Where-Object { $_.Trim() }) if ($lines.Count -eq 0) { return } # Parse the tail as one array; a line codex is still appending breaks that, so # fall back to parsing line by line and dropping the bad ones. $records = @(ConvertFrom-CodexJson -Json ('[' + ($lines -join ',') + ']')) if ($records.Count -eq 0) { $records = @(foreach ($line in $lines) { ConvertFrom-CodexJson -Json $line }) } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # The index is append-only: walk it newest first so a renamed thread keeps its latest name. for ($index = $records.Count - 1; $index -ge 0; $index--) { $record = $records[$index] if ($record -isnot [System.Collections.IDictionary]) { continue } $id = [string]$record['id'] if ([string]::IsNullOrWhiteSpace($id) -or -not $seen.Add($id)) { continue } $threadName = ([string]$record['thread_name'] -replace '\s+', ' ').Trim() $label = if ($threadName) { "$threadName ($id)" } else { $id } [pscustomobject]@{ Value = $id Alias = $threadName ListItem = $label ToolTip = ('{0} updated {1}' -f $label, [string]$record['updated_at']) } } } } function Get-CodexModelValue { $cachePath = Join-Path -Path (Get-CodexHomePath) -ChildPath 'models_cache.json' Get-CodexStateFileValue -Kind 'model' -Path $cachePath -Parser { param($Path) $json = Get-Content -LiteralPath $Path -Raw -ErrorAction Ignore if (-not $json) { return } $document = ConvertFrom-CodexJson -Json $json if ($document -isnot [System.Collections.IDictionary] -or $null -eq $document['models']) { return } foreach ($model in @($document['models'])) { if ($model -isnot [System.Collections.IDictionary] -or [string]::IsNullOrWhiteSpace([string]$model['slug'])) { continue } $slug = [string]$model['slug'] $tip = @([string]$model['display_name'], [string]$model['description']) | Where-Object { $_ } [pscustomobject]@{ Value = $slug Alias = '' ListItem = $slug ToolTip = if ($tip) { ($tip -join ' - ') } else { $slug } } } } } function Get-CodexConfigTableName { <# .SYNOPSIS Names of the direct child tables of [plugins] or [mcp_servers] in $CODEX_HOME/config.toml. Reads table headers only, never their values. #> param([ValidateSet('plugins', 'mcp_servers')][string]$Table) $configPath = Join-Path -Path (Get-CodexHomePath) -ChildPath 'config.toml' Get-CodexStateFileValue -Kind $Table -Path $configPath -Parser { param($Path) $text = Get-Content -LiteralPath $Path -Raw -ErrorAction Ignore if (-not $text) { return } $pattern = '(?m)^[ \t]*\[[ \t]*' + $Table + '[ \t]*\.[ \t]*(?:"(?<name>[^"\r\n]+)"|''(?<name>[^''\r\n]+)''|(?<name>[A-Za-z0-9_-]+))[ \t]*\][ \t]*(?:#[^\r\n]*)?\r?$' $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($match in [regex]::Matches($text, $pattern)) { $name = $match.Groups['name'].Value if ($names.Add($name)) { [pscustomobject]@{ Value = $name; Alias = ''; ListItem = $name; ToolTip = $name } } } } } function Get-CodexProfileValue { # -p/--profile layers $CODEX_HOME/<name>.config.toml on top of config.toml. foreach ($file in @(Get-ChildItem -LiteralPath (Get-CodexHomePath) -Filter '*.config.toml' -File -Force -ErrorAction Ignore)) { if ($file.Name.Length -le '.config.toml'.Length -or -not $file.Name.EndsWith('.config.toml', [System.StringComparison]::OrdinalIgnoreCase)) { continue } $name = $file.Name.Substring(0, $file.Name.Length - '.config.toml'.Length) [pscustomobject]@{ Value = $name; Alias = ''; ListItem = $name; ToolTip = $file.FullName } } } function Get-CodexFeatureValue { <# .SYNOPSIS Feature keys from the read-only 'codex features list', run once per session per launcher (bounded, stdin closed) and negatively cached. #> $codexExecutablePath = Get-CodexCompletionExecutablePath if ([string]::IsNullOrWhiteSpace($codexExecutablePath)) { return } $cached = Get-Variable -Name CodexFeatureCache -Scope Script -ErrorAction Ignore if ($null -ne $cached -and $cached.Value.Path -eq $codexExecutablePath) { return $cached.Value.Values } $output = '' if (-not $codexExecutablePath.EndsWith('.ps1', [System.StringComparison]::OrdinalIgnoreCase)) { $errorCountBefore = $Error.Count $process = [System.Diagnostics.Process]::new() try { $info = [System.Diagnostics.ProcessStartInfo]::new($codexExecutablePath) $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardInput = $true $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true $info.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $info.Environment['NO_COLOR'] = '1' [void]$info.ArgumentList.Add('features') [void]$info.ArgumentList.Add('list') $process.StartInfo = $info [void]$process.Start() $process.StandardInput.Close() $stdout = $process.StandardOutput.ReadToEndAsync() [void]$process.StandardError.ReadToEndAsync() if ($process.WaitForExit(5000)) { $output = $stdout.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } else { $process.Kill($true) } } catch { $output = '' } finally { $process.Dispose() # A launcher that fails to start is a negative-cache miss, not a fault to leave in $Error. while ($Error.Count -gt $errorCountBefore) { $Error.RemoveAt(0) } } } $values = @(foreach ($line in ($output -split '\r?\n')) { # '<key> <stage> <true|false>'; removed keys are accepted but do nothing, so skip them. if ($line -notmatch '^(?<name>[A-Za-z0-9_.-]+)\s+(?<stage>\S.*?)\s+(?<state>true|false)\s*$' -or $Matches.stage -eq 'removed') { continue } $state = if ($Matches.state -eq 'true') { 'enabled' } else { 'disabled' } [pscustomobject]@{ Value = $Matches.name; Alias = ''; ListItem = $Matches.name; ToolTip = "$($Matches.stage), currently $state" } }) $script:CodexFeatureCache = @{ Path = $codexExecutablePath; Values = $values } $values } function ConvertTo-CodexQuotedValue { param( [string]$Value, [string]$QuoteCharacter ) if ($QuoteCharacter -eq '"') { return '"' + ($Value -replace '([`"$])', '`$1') + '"' } if ($QuoteCharacter -eq "'" -or $Value -match '[\s{}();,|&<>''"`$]|^[@#]') { return "'" + $Value.Replace("'", "''") + "'" } $Value } function Get-CodexStateCandidate { param([ValidateSet('session', 'model', 'profile', 'feature', 'plugin', 'mcp')][string]$Slot) switch ($Slot) { 'session' { Get-CodexSessionValue } 'model' { Get-CodexModelValue } 'profile' { Get-CodexProfileValue } 'feature' { Get-CodexFeatureValue } 'plugin' { Get-CodexConfigTableName -Table 'plugins' } 'mcp' { Get-CodexConfigTableName -Table 'mcp_servers' } } } function ConvertTo-CodexStateCompletion { param( [object[]]$Candidate, [string]$ValueWord, [string]$ValuePrefix ) $quoteCharacter = '' if ($ValueWord.Length -gt 0 -and ($ValueWord[0] -eq "'" -or $ValueWord[0] -eq '"')) { $quoteCharacter = [string]$ValueWord[0] # PowerShell hands an unterminated quoted word over with its closing quote added. $ValueWord = $ValueWord.Substring(1) if ($ValueWord.EndsWith($quoteCharacter)) { $ValueWord = $ValueWord.Substring(0, $ValueWord.Length - 1) } } foreach ($item in $Candidate) { if ($null -eq $item) { continue } if (-not $item.Value.StartsWith($ValueWord, [System.StringComparison]::OrdinalIgnoreCase) -and -not ($item.Alias -and $item.Alias.StartsWith($ValueWord, [System.StringComparison]::OrdinalIgnoreCase))) { continue } $completionText = $ValuePrefix + (ConvertTo-CodexQuotedValue -Value $item.Value -QuoteCharacter $quoteCharacter) [System.Management.Automation.CompletionResult]::new($completionText, $item.ListItem, 'ParameterValue', $item.ToolTip) } } function Get-CodexValueCompletion { <# .SYNOPSIS Value overlay for slots the clap-generated script leaves empty: closed enum options, directory options, the 'completion <SHELL>' positional, and values read from local codex state (sessions, models, profiles, plugins, MCP servers, features). $Handled is set when a state-backed slot owns the word, so the caller does not fall back to the generated option list. #> param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition, [ref]$Handled ) $enumValues = [System.Collections.Generic.Dictionary[string, string[]]]::new([System.StringComparer]::Ordinal) $enumValues['-s'] = @('read-only', 'workspace-write', 'danger-full-access') $enumValues['--sandbox'] = $enumValues['-s'] $enumValues['-a'] = @('on-request', 'never') $enumValues['--ask-for-approval'] = $enumValues['-a'] $enumValues['--local-provider'] = @('lmstudio', 'ollama') $enumValues['--color'] = @('always', 'never', 'auto') $directoryOptions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($name in @('-C', '--cd', '--add-dir')) { [void]$directoryOptions.Add($name) } $stateOptions = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) $stateOptions['-m'] = 'model' $stateOptions['--model'] = 'model' $stateOptions['-p'] = 'profile' $stateOptions['--profile'] = 'profile' $stateOptions['--enable'] = 'feature' $stateOptions['--disable'] = 'feature' $statePositionals = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) foreach ($sessionNode in @('resume', 'fork', 'archive', 'delete', 'unarchive', 'exec;resume', 'exec;fork', 'e;resume', 'e;fork')) { $statePositionals['codex;' + $sessionNode] = 'session' } $statePositionals['codex;plugin;remove'] = 'plugin' foreach ($mcpNode in @('get', 'remove', 'login', 'logout')) { $statePositionals['codex;mcp;' + $mcpNode] = 'mcp' } $statePositionals['codex;features;enable'] = 'feature' $statePositionals['codex;features;disable'] = 'feature' $committed = @( $CommandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.EndOffset -lt $CursorPosition } | ForEach-Object { $_.Extent.Text } ) $previousToken = if ($committed.Count -gt 0) { $committed[-1] } else { '' } $node = 'codex' foreach ($token in $committed) { if ($token.StartsWith('-')) { break } $node += ';' + $token } $optionName = $null $valuePrefix = '' $valueWord = $WordToComplete if ($WordToComplete -match '^(?<option>--?[A-Za-z][A-Za-z0-9-]*)=(?<value>.*)$') { $optionName = $Matches.option $valuePrefix = $optionName + '=' $valueWord = $Matches.value } elseif ($previousToken.StartsWith('-')) { $optionName = $previousToken } if ($optionName) { if ($enumValues.ContainsKey($optionName)) { return @(foreach ($value in $enumValues[$optionName]) { if ($value.StartsWith($valueWord, [System.StringComparison]::OrdinalIgnoreCase)) { [System.Management.Automation.CompletionResult]::new($valuePrefix + $value, $value, 'ParameterValue', $value) } }) } if ($directoryOptions.Contains($optionName)) { return @(foreach ($item in [System.Management.Automation.CompletionCompleters]::CompleteFilename($valueWord)) { if ($item.ResultType -ne [System.Management.Automation.CompletionResultType]::ProviderContainer) { continue } if ($valuePrefix) { [System.Management.Automation.CompletionResult]::new($valuePrefix + $item.CompletionText, $item.ListItemText, $item.ResultType, $item.ToolTip) } else { $item } }) } $stateSlot = $null if ($stateOptions.ContainsKey($optionName)) { $stateSlot = $stateOptions[$optionName] } elseif ($optionName -ceq '--thread' -and $node -eq 'codex;queue') { $stateSlot = 'session' } if ($stateSlot -and -not $valueWord.StartsWith('-')) { $Handled.Value = $true return @(ConvertTo-CodexStateCompletion -Candidate @(Get-CodexStateCandidate -Slot $stateSlot) -ValueWord $valueWord -ValuePrefix $valuePrefix) } return @() } if ($statePositionals.ContainsKey($node) -and -not $WordToComplete.StartsWith('-')) { $Handled.Value = $true return @(ConvertTo-CodexStateCompletion -Candidate @(Get-CodexStateCandidate -Slot $statePositionals[$node]) -ValueWord $WordToComplete -ValuePrefix '') } if ($node -eq 'codex;completion' -and -not $WordToComplete.StartsWith('-')) { return @(foreach ($shell in @('bash', 'elvish', 'fish', 'powershell', 'zsh')) { if ($shell.StartsWith($WordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) { [System.Management.Automation.CompletionResult]::new($shell, $shell, 'ParameterValue', "Generate $shell completions") } }) } @() } function Invoke-CodexCompletion { [CmdletBinding()] [OutputType([object[]])] param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $handled = $false $valueResults = @(Get-CodexValueCompletion -WordToComplete $WordToComplete -CommandAst $CommandAst -CursorPosition $CursorPosition -Handled ([ref]$handled)) if ($handled -or $valueResults.Count -gt 0) { $valueResults return } $completionInvoker = Get-CodexCompletionInvoker if ($null -eq $completionInvoker) { return } & $completionInvoker $WordToComplete $CommandAst $CursorPosition } Register-ArgumentCompleter -Native -CommandName @('codex', 'codex.cmd', 'codex.ps1') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Invoke-CodexCompletion -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } |