diff --git a/rb.ps1 b/rb.ps1 new file mode 100644 index 0000000..9b2b368 --- /dev/null +++ b/rb.ps1 @@ -0,0 +1,372 @@ +# Roslyn Bridge PowerShell helper +# +# Purpose: Fast, low‑token wrapper around the Roslyn Bridge Web API (port 5001) +# and the legacy VS plugin (port 59123). Auto-detects which is running +# and provides concise commands for common operations. +# +# Usage examples: +# .\rb.ps1 health +# .\rb.ps1 projects +# .\rb.ps1 overview +# .\rb.ps1 diagnostics -FilePath C:/path/to/File.cs +# .\rb.ps1 symbol -FilePath C:/path/to/File.cs -Line 12 -Column 4 +# .\rb.ps1 references -FilePath C:/path/to/File.cs -Line 12 -Column 4 +# .\rb.ps1 search -SymbolName MyService -Kind class +# .\rb.ps1 build -ProjectName MyProject +# .\rb.ps1 package-add -ProjectName MyProject -PackageName Newtonsoft.Json -Version 13.0.3 +# .\rb.ps1 history-recent -Count 10 +# .\rb.ps1 query -BodyJson '{"queryType":"searchcode","symbolName":".*Controller"}' +# +# Output: +# - Compressed JSON by default (minimal tokens) +# - Add -Pretty for indented JSON +# - Add -Raw to return the raw response string + +[CmdletBinding(PositionalBinding=$true)] +param( + [Parameter(Position=0, Mandatory=$true)] + [ValidateSet( + 'health','projects','overview','diagnostics','symbol','references','search', + 'build','package-add','history','history-recent','history-stats', + 'plugin-health','solution-overview' + )] + [string]$Command, + + # Common params + [string]$BaseUrl, # Overrides auto-detected base URL + [switch]$NoDetect, # Skip auto-detection + [int]$TimeoutSec = 6, # Short default to stay snappy + [switch]$Raw, # Emit raw response string + [switch]$Pretty, # Indented JSON output + + # Query params + [string]$FilePath, + [int]$Line, + [int]$Column, + [string]$SymbolName, + [ValidateSet('class','method','property','field','interface','enum','struct','namespace','event','any')] + [string]$Kind = 'any', + + # Project ops + [string]$ProjectName, + [string]$Configuration, + [string]$PackageName, + [string]$Version, + + # History + [int]$Count = 25, + + # Solution overview summary options + [ValidateSet('json','text','yaml')] + [string]$Format = 'json', + [int]$Top = 5, + [bool]$IncludeNamespaces = $true +) + +set-strictmode -version latest +$ErrorActionPreference = 'Stop' + +function Write-Err { + param([string]$Msg) + Write-Error -Message $Msg +} + +function Get-JsonOutput { + param( + [Parameter(Mandatory=$true)] $Data + ) + if ($Raw) { + # If we were given an object, compress to a compact string; if it's already a string, emit it. + if ($Data -is [string]) { return $Data } + return ($Data | ConvertTo-Json -Depth 50 -Compress) + } + $json = $Data | ConvertTo-Json -Depth 50 -Compress + if ($Pretty) { $json = ($Data | ConvertTo-Json -Depth 50) } + return $json +} + +function Test-Url { + param( + [string]$Url, + [ValidateSet('GET','POST')][string]$Method = 'GET' + ) + try { + if ($Method -eq 'GET') { + $resp = Invoke-WebRequest -Method GET -Uri $Url -TimeoutSec $TimeoutSec + } else { + $resp = Invoke-WebRequest -Method POST -Uri $Url -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body '{}' + } + return $true + } catch { + return $false + } +} + +function Get-BridgeEndpoints { + # Decide which base URL to use. + # Priority: explicit -BaseUrl -> Web API (5001) if healthy -> Plugin (59123) if healthy + $web = 'http://localhost:5001' + $plugin = 'http://localhost:59123' + + if ($BaseUrl) { + return @{ BaseUrl = $BaseUrl; Mode = 'explicit' } + } + + if ($NoDetect) { + return @{ BaseUrl = $web; Mode = 'web' } + } + + if (Test-Url -Url "$web/api/health/ping" -Method GET) { + return @{ BaseUrl = $web; Mode = 'web' } + } + + if (Test-Url -Url "$plugin/health" -Method POST) { + return @{ BaseUrl = $plugin; Mode = 'plugin' } + } + + # Fallback to web + return @{ BaseUrl = $web; Mode = 'web' } +} + +function Invoke-WebApi { + param( + [string]$BaseUrl, + [ValidateSet('GET','POST')][string]$Method, + [string]$Path, + [hashtable]$Query, + $Body + ) + $uri = [System.UriBuilder]::new($BaseUrl) + if ($Path.StartsWith('/')) { $uri.Path = $Path } else { $uri.Path = "$($uri.Path.TrimEnd('/'))/$Path" } + if ($Query) { + $qs = [System.Web.HttpUtility]::ParseQueryString([string]::Empty) + foreach ($k in $Query.Keys) { + if ($null -ne $Query[$k] -and $Query[$k] -ne '') { $qs[$k] = [string]$Query[$k] } + } + $uri.Query = $qs.ToString() + } + if ($Raw) { + if ($Method -eq 'GET') { + return (Invoke-WebRequest -Method GET -Uri $uri.Uri -TimeoutSec $TimeoutSec).Content + } else { + $bodyStr = if ($Body -is [string]) { $Body } else { ($Body | ConvertTo-Json -Depth 50 -Compress) } + return (Invoke-WebRequest -Method POST -Uri $uri.Uri -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body $bodyStr).Content + } + } + if ($Method -eq 'GET') { + return Invoke-RestMethod -Method GET -Uri $uri.Uri -TimeoutSec $TimeoutSec + } else { + if ($Body -is [string]) { + return Invoke-RestMethod -Method POST -Uri $uri.Uri -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body $Body + } else { + return Invoke-RestMethod -Method POST -Uri $uri.Uri -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body ($Body | ConvertTo-Json -Depth 50 -Compress) + } + } +} + +function Invoke-PluginApi { + param( + [string]$BaseUrl, + [string]$Path, # '/query' or '/health' + $Body # hashtable or string + ) + $uri = "$BaseUrl$Path" + if ($Raw) { + $bodyStr = if ($Body -is [string]) { $Body } else { ($Body | ConvertTo-Json -Depth 50 -Compress) } + return (Invoke-WebRequest -Method POST -Uri $uri -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body $bodyStr).Content + } + if ($Body -is [string]) { + return Invoke-RestMethod -Method POST -Uri $uri -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body $Body + } else { + return Invoke-RestMethod -Method POST -Uri $uri -TimeoutSec $TimeoutSec -ContentType 'application/json' -Body ($Body | ConvertTo-Json -Depth 50 -Compress) + } +} + +# +# Main dispatch +# + +$endpoint = Get-BridgeEndpoints +$mode = $endpoint.Mode +$base = $endpoint.BaseUrl + +try { + switch ($Command) { + 'health' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/health' + $out = Get-JsonOutput -Data $resp + Write-Output $out + } else { + $resp = Invoke-PluginApi -BaseUrl $base -Path '/health' -Body '{}' + $out = Get-JsonOutput -Data $resp + Write-Output $out + } + } + 'projects' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/projects' + } else { + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body @{ queryType = 'getprojects' } + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'overview' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/solution/overview' + } else { + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body @{ queryType = 'getsolutionoverview' } + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'diagnostics' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{} + if ($FilePath) { $q.filePath = $FilePath } + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/diagnostics' -Query $q + } else { + $body = @{ queryType = 'getdiagnostics' } + if ($FilePath) { $body.filePath = $FilePath } + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body $body + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'symbol' { + if (-not $FilePath -or -not $PSBoundParameters.ContainsKey('Line') -or -not $PSBoundParameters.ContainsKey('Column')) { + Write-Err 'symbol requires -FilePath, -Line, -Column'; exit 2 + } + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{ filePath=$FilePath; line=$Line; column=$Column } + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/symbol' -Query $q + } else { + $body = @{ queryType='getsymbol'; filePath=$FilePath; line=$Line; column=$Column } + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body $body + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'references' { + if (-not $FilePath -or -not $PSBoundParameters.ContainsKey('Line') -or -not $PSBoundParameters.ContainsKey('Column')) { + Write-Err 'references requires -FilePath, -Line, -Column'; exit 2 + } + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{ filePath=$FilePath; line=$Line; column=$Column } + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/references' -Query $q + } else { + $body = @{ queryType='findreferences'; filePath=$FilePath; line=$Line; column=$Column } + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body $body + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'search' { + if (-not $SymbolName) { Write-Err 'search requires -SymbolName'; exit 2 } + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{ symbolName=$SymbolName } + if ($Kind -and $Kind -ne 'any') { $q.kind = $Kind } + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/symbol/search' -Query $q + } else { + $parameters = @{} + if ($Kind -and $Kind -ne 'any') { $parameters.kind = $Kind } + $body = @{ queryType='findsymbol'; symbolName=$SymbolName } + if ($parameters.Count -gt 0) { $body.parameters = $parameters } + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body $body + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'build' { + if (-not $ProjectName) { Write-Err 'build requires -ProjectName'; exit 2 } + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{ projectName=$ProjectName } + if ($Configuration) { $q.configuration = $Configuration } + $resp = Invoke-WebApi -BaseUrl $base -Method POST -Path '/api/roslyn/project/build' -Query $q + } else { + $body = @{ queryType='buildproject'; projectName=$ProjectName } + if ($Configuration) { $body.configuration = $Configuration } + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body $body + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'package-add' { + if (-not $ProjectName -or -not $PackageName) { Write-Err 'package-add requires -ProjectName and -PackageName'; exit 2 } + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{ projectName=$ProjectName; packageName=$PackageName } + if ($Version) { $q.version = $Version } + $resp = Invoke-WebApi -BaseUrl $base -Method POST -Path '/api/roslyn/project/package/add' -Query $q + } else { + $body = @{ queryType='addnugetpackage'; projectName=$ProjectName; packageName=$PackageName } + if ($Version) { $body.version = $Version } + $resp = Invoke-PluginApi -BaseUrl $base -Path '/query' -Body $body + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'history' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/history' + } else { + Write-Err 'history is only available via Web API (port 5001).'; exit 2 + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'history-recent' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $q = @{ count = $Count } + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/history/recent' -Query $q + } else { + Write-Err 'history-recent is only available via Web API (port 5001).'; exit 2 + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'history-stats' { + if ($mode -eq 'web' -or $mode -eq 'explicit') { + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/history/stats' + } else { + Write-Err 'history-stats is only available via Web API (port 5001).'; exit 2 + } + Write-Output (Get-JsonOutput -Data $resp) + } + 'plugin-health' { + $pluginBase = if ($BaseUrl) { $BaseUrl } else { 'http://localhost:59123' } + $resp = Invoke-PluginApi -BaseUrl $pluginBase -Path '/health' -Body '{}' + Write-Output (Get-JsonOutput -Data $resp) + } + 'solution-overview' { + if ($mode -ne 'web' -and $mode -ne 'explicit') { + Write-Err 'solution-overview requires the Web API (port 5001).'; exit 2 + } + $q = @{ topNProjects=$Top; includeNamespaces=$IncludeNamespaces; format=$Format } + # For text/yaml formats, we want the raw response as-is + if ($Format -eq 'json') { + $resp = Invoke-WebApi -BaseUrl $base -Method GET -Path '/api/roslyn/solution/overview/summary' -Query $q + Write-Output (Get-JsonOutput -Data $resp) + } else { + # Build query string properly + $qs = [System.Web.HttpUtility]::ParseQueryString([string]::Empty) + $qs['topNProjects'] = [string]$Top + $qs['includeNamespaces'] = [string]$IncludeNamespaces + $qs['format'] = $Format + $uri = [System.UriBuilder]::new("$base/api/roslyn/solution/overview/summary") + $uri.Query = $qs.ToString() + $content = (Invoke-WebRequest -Method GET -Uri $uri.Uri -TimeoutSec $TimeoutSec).Content + Write-Output $content + } + } + default { + Write-Err "Unknown command: $Command"; exit 2 + } + } +} catch { + $msg = $_.Exception.Message + $respProp = $_.Exception.PSObject.Properties['Response'] + if ($respProp -and $respProp.Value) { + try { + $resp = $respProp.Value + $stream = $resp.GetResponseStream() + if ($stream) { + $reader = New-Object System.IO.StreamReader($stream) + $content = $reader.ReadToEnd() + if ($content) { $msg = "$msg`n$content" } + } + } catch {} + } + Write-Err $msg + exit 1 +}