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 SilentlyContinue | 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 Get-CursorAgentPathCompletions { param([string]$WordToComplete) if ([string]::IsNullOrWhiteSpace($WordToComplete) -or $WordToComplete -eq '.') { $WordToComplete = '' } $prefix = $WordToComplete if ($prefix.StartsWith('~')) { $prefix = $prefix -replace '^~', $HOME } $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 @() } $items = @(Get-ChildItem -LiteralPath $basePath -Force -ErrorAction SilentlyContinue) $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 + [System.IO.Path]::DirectorySeparatorChar } $displayPath = $displayText if ($prefix -match '[\\/]' -or $prefix.StartsWith('~') -or $prefix.StartsWith('.')) { $displayPath = Join-Path -Path $basePath -ChildPath $item.Name if ($item.PSIsContainer) { $displayPath = $displayPath + [System.IO.Path]::DirectorySeparatorChar } } [void]$results.Add((New-CursorAgentCompletionResult -CompletionText $displayPath -ResultType 'ParameterValue' -ToolTip $item.FullName -ListItemText $displayText)) } 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] $tokens = @() if ($null -ne $commandAst) { $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text }) if ($elements.Count -gt 1) { $tokens = @($elements | Select-Object -Skip 1) if ($tokens.Count -gt 0 -and $tokens[-1] -eq $wordToComplete) { $tokens = @($tokens | Select-Object -First ($tokens.Count - 1)) } } } $prefix = $wordToComplete # 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++ 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 = @() } } $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) { $canonicalName = Get-CursorAgentCanonicalOptionName -Option $option $valueHints = Get-CursorAgentValueHints if ($valueHints.ContainsKey($canonicalName)) { foreach ($hint in @($valueHints[$canonicalName])) { if ([string]::IsNullOrWhiteSpace($prefix) -or $hint -like ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')) { [void]$results.Add((New-CursorAgentCompletionResult -CompletionText $hint -ResultType 'ParameterValue' -ToolTip $option.Description -ListItemText $hint)) } } return @($results.ToArray()) } if ($canonicalName -in (Get-CursorAgentPathOptions)) { return @(Get-CursorAgentPathCompletions -WordToComplete $prefix) } $placeholder = Get-CursorAgentPlaceholderValue -OptionName $canonicalName if ([string]::IsNullOrWhiteSpace($prefix) -or $placeholder -like ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')) { [void]$results.Add((New-CursorAgentCompletionResult -CompletionText $placeholder -ResultType 'ParameterValue' -ToolTip $option.Description -ListItemText $placeholder)) } return @($results.ToArray()) } } 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 } |