completers/cursor_agent_completer/cursor_agent_completer.ps1
|
<# .SYNOPSIS Registers a native PowerShell argument completer for the Cursor Agent CLI. .DESCRIPTION Provides standalone completion for `cursor-agent`, `cursor-agent.cmd`, and `cursor-agent.ps1` using live help output from the installed CLI. #> Set-StrictMode -Version 2.0 function New-CursorAgentCompletionResult { 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 Get-CursorAgentCommandPath { foreach ($candidate in @('cursor-agent.cmd', 'cursor-agent.ps1', 'cursor-agent')) { $command = Get-Command -Name $candidate -CommandType Application, ExternalScript -ErrorAction Ignore | Select-Object -First 1 if ($null -ne $command) { if ($command.Source) { return $command.Source } return $command.Name } } return $null } function Get-CursorAgentRootHelpOutput { $commandPath = Get-CursorAgentCommandPath if ([string]::IsNullOrWhiteSpace($commandPath)) { return '' } try { return ($null | & $commandPath --help 2>&1 | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String) } catch { return '' } } function Get-CursorAgentSubcommandHelpOutput { param([string[]]$CommandPath) $launcherPath = Get-CursorAgentCommandPath if ([string]::IsNullOrWhiteSpace($launcherPath) -or @($CommandPath).Count -eq 0) { return '' } try { return ($null | & $launcherPath @CommandPath --help 2>&1 | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String) } catch { return '' } } function ConvertFrom-CursorAgentHelpText { param([string]$HelpText) $lines = @([regex]::Split($HelpText, '\r?\n')) $options = New-Object System.Collections.Generic.List[object] $subcommands = New-Object System.Collections.Generic.List[string] $inOptions = $false $inCommands = $false for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] $trimmed = $line.Trim() if ($trimmed -eq 'Options:') { $inOptions = $true $inCommands = $false continue } if ($trimmed -eq 'Commands:') { $inOptions = $false $inCommands = $true continue } if ($trimmed -eq 'Arguments:') { $inOptions = $false $inCommands = $false continue } if ($inOptions) { if ([string]::IsNullOrWhiteSpace($trimmed)) { $inOptions = $false continue } $contentLine = $line.TrimStart() $parts = [regex]::Split($contentLine, '\s{2,}', 2) if ($parts.Count -lt 2) { continue } $namesPart = $parts[0].Trim() $description = $parts[1].Trim() $optionNames = @([regex]::Matches($namesPart, '(?<!\S)(--?[A-Za-z0-9][A-Za-z0-9-]*)(?=(\s|,|$))') | ForEach-Object { $_.Value.Trim() }) if ($optionNames.Count -eq 0) { continue } $takesValue = ($namesPart -match '<[^>]+>') -or ($namesPart -match '\[[^]]*\]') [void]$options.Add([pscustomobject]@{ Names = @($optionNames) TakesValue = [bool]$takesValue Description = $description }) continue } if ($inCommands) { if ([string]::IsNullOrWhiteSpace($trimmed)) { $inCommands = $false continue } $contentLine = $line.TrimStart() $parts = [regex]::Split($contentLine, '\s{2,}', 2) if ($parts.Count -lt 2) { continue } $commandToken = $parts[0].Trim() if ([string]::IsNullOrWhiteSpace($commandToken)) { continue } $commandToken = ($commandToken -split '\s', 2)[0] $commandToken = ($commandToken -split '<', 2)[0] $commandNames = @($commandToken.Split('|') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) foreach ($commandName in $commandNames) { if ($commandName -match '^[A-Za-z0-9][A-Za-z0-9-]*$') { [void]$subcommands.Add($commandName) } } } } [pscustomobject]@{ Options = @($options.ToArray()) Subcommands = @($subcommands.ToArray() | Sort-Object -Unique) } } function Get-CursorAgentCompletionCatalog { $existing = Get-Variable -Name 'CursorAgentCompletionCatalog' -Scope Script -ErrorAction Ignore if ($null -ne $existing -and $null -ne $existing.Value) { return $existing.Value } $catalog = [ordered]@{ Initialized = $false CommandPath = $null RootHelpText = '' RootOptions = @() RootSubcommands = @() SubcommandCatalogs = @{} } Set-Variable -Name 'CursorAgentCompletionCatalog' -Value $catalog -Scope Script return (Get-Variable -Name 'CursorAgentCompletionCatalog' -Scope Script).Value } function Initialize-CursorAgentCompletionCatalog { $catalog = Get-CursorAgentCompletionCatalog if ($catalog.Initialized) { return $catalog } $catalog.CommandPath = Get-CursorAgentCommandPath $catalog.RootHelpText = Get-CursorAgentRootHelpOutput $rootHelp = ConvertFrom-CursorAgentHelpText -HelpText $catalog.RootHelpText $catalog.RootOptions = @($rootHelp.Options) $catalog.RootSubcommands = @($rootHelp.Subcommands) $catalog.Initialized = $true Set-Variable -Name 'CursorAgentCompletionCatalog' -Value $catalog -Scope Script return (Get-Variable -Name 'CursorAgentCompletionCatalog' -Scope Script).Value } function Get-CursorAgentNodeCatalog { <# .SYNOPSIS Returns the parsed help (Options + Subcommands) for a validated subcommand path, fetching '<path> --help' once per session and caching misses too. #> param([string[]]$CommandPath) $catalog = Initialize-CursorAgentCompletionCatalog if (@($CommandPath).Count -eq 0) { return [pscustomobject]@{ Options = @($catalog.RootOptions) Subcommands = @($catalog.RootSubcommands) } } $key = $CommandPath -join ' ' if (-not $catalog.SubcommandCatalogs.ContainsKey($key)) { $helpText = Get-CursorAgentSubcommandHelpOutput -CommandPath $CommandPath $catalog.SubcommandCatalogs[$key] = if ([string]::IsNullOrWhiteSpace($helpText)) { $null } else { ConvertFrom-CursorAgentHelpText -HelpText $helpText } } $catalog.SubcommandCatalogs[$key] } function Find-CursorAgentOption { param( $Node, [string]$Token ) if ($null -eq $Node) { return $null } foreach ($option in @($Node.Options)) { foreach ($name in @($option.Names)) { if ([string]::Equals($name, $Token, [System.StringComparison]::Ordinal)) { return $option } } } $null } function Get-CursorAgentCanonicalOptionName { param($Option) foreach ($name in @($Option.Names)) { if ($name.StartsWith('--')) { return $name } } @($Option.Names)[0] } function Get-CursorAgentValueHints { @{ '--mode' = @('plan', 'ask') '--output-format' = @('text', 'json', 'stream-json') '--sandbox' = @('enabled', 'disabled') '--format' = @('text', 'json') } } function Get-CursorAgentPathOptions { @('--workspace', '--add-dir', '--plugin-dir') } function Get-CursorAgentPlaceholderValue { param([string]$OptionName) switch ($OptionName) { '--api-key' { return '<key>' } '--endpoint' { return '<url>' } '--header' { return '<header>' } '--model' { return '<model>' } '--resume' { return '<chatId>' } '--worktree' { return '<name>' } '--worktree-base' { return '<branch>' } default { return '<value>' } } } function ConvertFrom-CursorAgentTypedWord { # Splits the text typed so far into its value and the opening quote the # user typed ('' when bare), undoing that quote style's escapes. param([string]$Text) $quote = '' if ($Text.StartsWith("'") -or $Text.StartsWith('"')) { $quote = $Text.Substring(0, 1) $Text = $Text.Substring(1) if ($Text.EndsWith($quote)) { $Text = $Text.Substring(0, $Text.Length - 1) } $Text = if ($quote -eq "'") { $Text.Replace("''", "'") } else { $Text -replace '`(.)', '$1' } } [pscustomobject]@{ Value = $Text; Quote = $quote } } function Test-CursorAgentArgumentNeedsQuote { # Whitespace and argument-mode metacharacters (including the typographic # quotes PowerShell treats as quotes) end or split a bare word; a leading # '@' or '#' would start a splat or a comment. param([string]$Value) return ($Value -match '[\s{}();,|&<>''"`$\u2018-\u201E]' -or $Value -match '^[@#]') } function ConvertTo-CursorAgentArgument { # Renders a value as one PowerShell argument: bare when safe and no quote # was typed, otherwise in the typed quote style (single by default). param( [string]$Value, [string]$Quote ) if (-not $Quote) { if (-not (Test-CursorAgentArgumentNeedsQuote -Value $Value)) { return $Value } $Quote = "'" } if ($Quote -eq "'") { return "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'" } return '"' + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + '"' } function Get-CursorAgentPathCompletions { param( [string]$WordToComplete, [string]$CompletionPrefix = '', [string]$Quote = '' ) if ([string]::IsNullOrWhiteSpace($WordToComplete) -or $WordToComplete -eq '.') { $WordToComplete = '' } $prefix = $WordToComplete if ($prefix.StartsWith('~')) { $prefix = $prefix -replace '^~', $HOME } $basePath = '' $leafName = '' if ($prefix -match '[\\/]$') { $basePath = $prefix } elseif ($prefix) { $basePath = Split-Path -Path $prefix -Parent $leafName = Split-Path -Path $prefix -Leaf } if ([string]::IsNullOrWhiteSpace($basePath)) { $basePath = (Get-Location).Path } if (-not (Test-Path -LiteralPath $basePath)) { return @() } # A typed directory part ('.\', '../', 'C:\dir/') is kept verbatim, with # the separator the user typed; '~' is expanded as before. $typedDirectory = '' $separator = [System.IO.Path]::DirectorySeparatorChar $separatorIndex = $WordToComplete.LastIndexOfAny([char[]]'\/') if ($separatorIndex -ge 0 -and -not $WordToComplete.StartsWith('~')) { $typedDirectory = $WordToComplete.Substring(0, $separatorIndex + 1) $separator = $WordToComplete[$separatorIndex] } $items = @(Get-ChildItem -LiteralPath $basePath -Force -ErrorAction Ignore) $results = New-Object System.Collections.Generic.List[object] foreach ($item in $items) { if (-not [string]::IsNullOrWhiteSpace($leafName) -and $item.Name -notlike "$leafName*") { continue } $displayText = $item.Name if ($item.PSIsContainer) { $displayText = $displayText + $separator } $displayPath = $displayText if ($typedDirectory) { $displayPath = $typedDirectory + $displayText } elseif ($WordToComplete.StartsWith('~')) { $displayPath = Join-Path -Path $basePath -ChildPath $item.Name if ($item.PSIsContainer) { $displayPath = $displayPath + [System.IO.Path]::DirectorySeparatorChar } } elseif (-not $CompletionPrefix -and $displayPath -match '^[-\u2013-\u2015]') { # PowerShell reads a bare word starting with a dash as a parameter # name; as PowerShell's own path completion does, lead with '.\'. $displayPath = '.' + $separator + $displayPath } # A quoted directory drops its trailing separator, as PowerShell's own # path completion does: '...\' would end in an escaped quote once the # cmd launcher re-quotes it for the CLI. $completionText = $CompletionPrefix + $displayPath if ($Quote -or (Test-CursorAgentArgumentNeedsQuote -Value $completionText)) { $completionText = ConvertTo-CursorAgentArgument -Value ($CompletionPrefix + $displayPath.TrimEnd('\', '/')) -Quote $Quote } [void]$results.Add((New-CursorAgentCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $item.FullName -ListItemText $displayText)) } return @($results.ToArray()) } function Get-CursorAgentOptionValueCompletion { param( $Option, [string]$WordToComplete, [string]$CompletionPrefix = '', [string]$Quote = '' ) $results = New-Object System.Collections.Generic.List[object] $canonicalName = Get-CursorAgentCanonicalOptionName -Option $Option $valueHints = Get-CursorAgentValueHints if ($valueHints.ContainsKey($canonicalName)) { foreach ($hint in @($valueHints[$canonicalName])) { if ([string]::IsNullOrWhiteSpace($WordToComplete) -or $hint -like ([System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*')) { [void]$results.Add((New-CursorAgentCompletionResult -CompletionText (ConvertTo-CursorAgentArgument -Value ($CompletionPrefix + $hint) -Quote $Quote) -ResultType 'ParameterValue' -ToolTip $Option.Description -ListItemText $hint)) } } return @($results.ToArray()) } if ($canonicalName -in (Get-CursorAgentPathOptions)) { return @(Get-CursorAgentPathCompletions -WordToComplete $WordToComplete -CompletionPrefix $CompletionPrefix -Quote $Quote) } $placeholder = Get-CursorAgentPlaceholderValue -OptionName $canonicalName if ([string]::IsNullOrWhiteSpace($WordToComplete) -or $placeholder -like ([System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*')) { [void]$results.Add((New-CursorAgentCompletionResult -CompletionText ($CompletionPrefix + $placeholder) -ResultType 'ParameterValue' -ToolTip $Option.Description -ListItemText $placeholder)) } return @($results.ToArray()) } function Complete-CursorAgent { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $null = Initialize-CursorAgentCompletionCatalog $results = New-Object System.Collections.Generic.List[object] # Committed tokens are the elements that end before the cursor; the word # under the cursor is read from its extent so a typed opening quote is # kept ($wordToComplete drops it for '--opt="value'). $tokens = @() $typedText = '' if ($null -ne $commandAst) { foreach ($element in @($commandAst.CommandElements | Select-Object -Skip 1)) { $extent = $element.Extent if ($extent.EndOffset -lt $cursorPosition) { $tokens = @($tokens + $extent.Text) } elseif ($extent.StartOffset -lt $cursorPosition) { $typedText = $extent.Text.Substring(0, $cursorPosition - $extent.StartOffset) } } } $typedWord = ConvertFrom-CursorAgentTypedWord -Text $typedText $prefix = $typedWord.Value # Walk the committed tokens to the deepest validated subcommand node. Each # node's options and subcommands come from its own '--help' (commander does # not inherit root options into subcommands). $commandPath = @() $node = Get-CursorAgentNodeCatalog -CommandPath @() $index = 0 while ($index -lt $tokens.Count) { $token = $tokens[$index] $index++ # Commander treats everything after '--' as positional arguments. if ($token -eq '--') { return @() } if ($token.StartsWith('-')) { $option = Find-CursorAgentOption -Node $node -Token $token if ($option -and $option.TakesValue -and -not $token.Contains('=') -and $index -lt $tokens.Count -and -not $tokens[$index].StartsWith('-')) { $index++ } continue } if ($null -ne $node -and (@($node.Subcommands) -ccontains $token)) { $commandPath = @($commandPath + $token) $node = Get-CursorAgentNodeCatalog -CommandPath $commandPath } } if ($null -eq $node) { $node = [pscustomobject]@{ Options = @(); Subcommands = @() } } # Attached long form '--opt=value' (commander accepts it; root help cites # '--mode=plan'): complete the value and keep '--opt=' in CompletionText. # A quote typed before the option or before the value quotes the whole # token, so PowerShell still passes it as one argument. if ($prefix -match '^(--[^=]+)=(.*)$') { $attachedName = $Matches[1] $attachedValue = [pscustomobject]@{ Value = $Matches[2]; Quote = $typedWord.Quote } if (-not $attachedValue.Quote) { $attachedValue = ConvertFrom-CursorAgentTypedWord -Text $attachedValue.Value } $option = Find-CursorAgentOption -Node $node -Token $attachedName if ($option -and $option.TakesValue) { return @(Get-CursorAgentOptionValueCompletion -Option $option -WordToComplete $attachedValue.Value -CompletionPrefix "$attachedName=" -Quote $attachedValue.Quote) } return @() } $previousToken = if ($tokens.Count -gt 0) { $tokens[-1] } else { $null } if ($previousToken -and $previousToken.StartsWith('-') -and -not $previousToken.Contains('=')) { $option = Find-CursorAgentOption -Node $node -Token $previousToken if ($option -and $option.TakesValue) { return @(Get-CursorAgentOptionValueCompletion -Option $option -WordToComplete $prefix -Quote $typedWord.Quote) } } foreach ($option in @($node.Options)) { foreach ($optionName in @($option.Names)) { if ([string]::IsNullOrWhiteSpace($prefix) -or $optionName -like ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')) { [void]$results.Add((New-CursorAgentCompletionResult -CompletionText $optionName -ResultType 'ParameterName' -ToolTip $option.Description -ListItemText $optionName)) } } } foreach ($subcommand in @($node.Subcommands)) { if ([string]::IsNullOrWhiteSpace($prefix) -or $subcommand -like ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')) { [void]$results.Add((New-CursorAgentCompletionResult -CompletionText $subcommand -ResultType 'ParameterValue' -ToolTip $subcommand -ListItemText $subcommand)) } } return @($results.ToArray()) } Register-ArgumentCompleter -Native -CommandName @('cursor-agent', 'cursor-agent.cmd', 'cursor-agent.ps1') -ScriptBlock { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) Complete-CursorAgent -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |