workflows/default/systems/runtime/ProviderCLI/ProviderCLI.psm1
|
using namespace System.Management.Automation <# .SYNOPSIS Provider-agnostic CLI abstraction layer for dotbot. .DESCRIPTION Wraps provider-specific CLIs (Claude, Codex, Gemini) behind a unified interface. Loads declarative provider config from workflows/default/settings/providers/{name}.json and dispatches CLI invocations accordingly. #> # Import DotBotTheme for consistent colors if (-not (Get-Module DotBotTheme)) { Import-Module "$PSScriptRoot\..\modules\DotBotTheme.psm1" -Force } # Import ClaudeCLI for reuse of its stream parser and helpers if (-not (Get-Module ClaudeCLI)) { Import-Module "$PSScriptRoot\..\ClaudeCLI\ClaudeCLI.psm1" -Force } #region Provider Config function Get-ProviderConfig { <# .SYNOPSIS Loads provider config JSON for the active (or specified) provider. .PARAMETER Name Provider name (claude, codex, gemini). If omitted, reads from settings. #> [CmdletBinding()] param( [string]$Name ) if (-not $Name) { # Read from settings $botRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $settingsPath = Join-Path $botRoot "settings\settings.default.json" $settings = @{ provider = 'claude' } if (Test-Path $settingsPath) { try { $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json } catch { Write-BotLog -Level Debug -Message "Settings operation failed" -Exception $_ } } # Check user override $controlSettings = Join-Path $botRoot ".control\settings.json" if (Test-Path $controlSettings) { try { $override = Get-Content $controlSettings -Raw | ConvertFrom-Json if ($override.provider) { $settings = @{ provider = $override.provider } } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } $Name = if ($settings.provider) { $settings.provider } else { 'claude' } } # Look for provider config in .bot first (installed project), then profiles (dev) $botRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $configPath = Join-Path $botRoot "settings\providers\$Name.json" if (-not (Test-Path $configPath)) { # Fallback to workflows source $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) $configPath = Join-Path $repoRoot "workflows\default\settings\providers\$Name.json" } if (-not (Test-Path $configPath)) { throw "Provider config not found for '$Name' at $configPath" } $config = Get-Content $configPath -Raw | ConvertFrom-Json return $config } function Get-ProviderModels { <# .SYNOPSIS Returns the model list for the active provider. #> [CmdletBinding()] param( [string]$ProviderName ) $config = Get-ProviderConfig -Name $ProviderName $models = @() foreach ($key in ($config.models.PSObject.Properties.Name)) { $m = $config.models.$key $models += [PSCustomObject]@{ Alias = $key Id = $m.id Description = $m.description Badge = if ($m.badge) { $m.badge } else { $null } IsDefault = ($key -eq $config.default_model) } } return $models } function Resolve-ProviderModelId { <# .SYNOPSIS Maps a model alias (e.g. "Opus") to the provider's configured CLI model selector. If the input is already a configured model selector, returns it as-is. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ModelAlias, [string]$ProviderName ) $config = Get-ProviderConfig -Name $ProviderName # Check if it's an alias if ($config.models.PSObject.Properties.Name -contains $ModelAlias) { return $config.models.$ModelAlias.id } # Check if it's already a full model ID foreach ($key in $config.models.PSObject.Properties.Name) { if ($config.models.$key.id -eq $ModelAlias) { return $ModelAlias } } throw "Unknown model '$ModelAlias' for provider '$($config.name)'. Valid models: $($config.models.PSObject.Properties.Name -join ', ')" } #endregion #region CLI Arg Building function Resolve-PermissionArgs { <# .SYNOPSIS Resolves the CLI permission arguments for a provider invocation. .PARAMETER Config Provider config object (from Get-ProviderConfig). .PARAMETER PermissionMode Requested permission mode key. If omitted or invalid, falls back to provider default. .PARAMETER DefaultArgs Fallback args array returned when no config-driven mode can be resolved. #> param( $Config, [string]$PermissionMode, [string[]]$DefaultArgs = @("--dangerously-skip-permissions") ) if ($PermissionMode -and $Config.permission_modes -and $Config.permission_modes.$PermissionMode) { return @($Config.permission_modes.$PermissionMode.cli_args) } if ($Config.default_permission_mode -and $Config.permission_modes -and $Config.permission_modes.$($Config.default_permission_mode)) { return @($Config.permission_modes.$($Config.default_permission_mode).cli_args) } if ($Config.cli_args.permissions_bypass) { return @($Config.cli_args.permissions_bypass) } return $DefaultArgs } function Build-ProviderCliArgs { <# .SYNOPSIS Builds the CLI argument array for a provider invocation. .PARAMETER Config Provider config object (from Get-ProviderConfig). .PARAMETER Prompt The prompt text. .PARAMETER ModelId Full model ID to use. .PARAMETER SessionId Optional session ID (only used if provider supports it). .PARAMETER PersistSession Whether to persist the session. .PARAMETER Streaming Whether to use streaming output format. #> [CmdletBinding()] param( [Parameter(Mandatory)] $Config, [Parameter(Mandatory)] [string]$Prompt, [Parameter(Mandatory)] [string]$ModelId, [string]$SessionId, [bool]$PersistSession = $false, [bool]$Streaming = $true, [string]$PermissionMode ) $args_ = @() # Exec subcommand (e.g. "exec" for Codex) if ($Config.exec_subcommand) { $args_ += $Config.exec_subcommand } # Model if ($Config.cli_args.model) { $args_ += $Config.cli_args.model, $ModelId } # Permission mode — resolve from permission_modes config, fall back to cli_args.permissions_bypass $permArgs = Resolve-PermissionArgs -Config $Config -PermissionMode $PermissionMode -DefaultArgs @() if ($permArgs) { $args_ += $permArgs } # Session ID (only if provider supports it) if ($SessionId -and $Config.capabilities.session_id -and $Config.cli_args.session_id) { $args_ = @($Config.cli_args.session_id, $SessionId) + $args_ } # No session persistence (only if provider supports it and we don't want persistence) if (-not $PersistSession -and $Config.capabilities.persist_session -and $Config.cli_args.no_session_persistence) { $args_ += $Config.cli_args.no_session_persistence } # Streaming format if ($Streaming -and $Config.cli_args.stream_format) { $args_ += @($Config.cli_args.stream_format) } # Print flag if ($Config.cli_args.print) { $args_ += $Config.cli_args.print } # Verbose flag if ($Config.cli_args.verbose) { $args_ += $Config.cli_args.verbose } # Prompt is delivered via stdin by callers to avoid Windows command-line length limits (#167) # The $Prompt parameter is retained for signature compatibility but not added to args. return $args_ } #endregion #region Invocation # Script-scoped variable to store rate limit info for caller to check $script:LastProviderRateLimitInfo = $null function Invoke-ProviderStream { <# .SYNOPSIS Invokes the active provider's CLI with streaming output and detailed logging. .DESCRIPTION Provider-agnostic replacement for Invoke-ClaudeStream. Builds CLI args from provider config, invokes the CLI, and dispatches output to the correct stream parser. .PARAMETER Prompt The prompt to send. .PARAMETER Model Full model ID to use (default: provider's default model). .PARAMETER SessionId Optional session ID for conversation continuity. .PARAMETER PersistSession Whether to persist the session. .PARAMETER ShowDebugJson Show raw JSON events. .PARAMETER ShowVerbose Show detailed tool results and metadata. .PARAMETER ProviderName Override provider name (default: from settings). #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [string]$Prompt, [Parameter(Position = 1)] [string]$Model, [string]$SessionId, [switch]$PersistSession, [switch]$ShowDebugJson, [switch]$ShowVerbose, [string]$ProviderName, [string]$PermissionMode ) # Clear any previous rate limit info $script:LastProviderRateLimitInfo = $null # Load provider config $config = Get-ProviderConfig -Name $ProviderName # Resolve model if (-not $Model) { $Model = $config.models.($config.default_model).id } # For Claude provider, delegate to existing Invoke-ClaudeStream (proven, battle-tested) if ($config.name -eq 'claude') { # Resolve permission args for Claude path $permArgs = Resolve-PermissionArgs -Config $config -PermissionMode $PermissionMode $streamArgs = @{ Prompt = $Prompt Model = $Model PermissionArgs = $permArgs } if ($SessionId) { $streamArgs['SessionId'] = $SessionId } if ($PersistSession) { $streamArgs['PersistSession'] = $true } if ($ShowDebugJson) { $streamArgs['ShowDebugJson'] = $true } if ($ShowVerbose) { $streamArgs['ShowVerbose'] = $true } Invoke-ClaudeStream @streamArgs # Propagate rate limit info $script:LastProviderRateLimitInfo = Get-LastRateLimitInfo return } # --- Non-Claude provider path --- # Refresh theme if (Update-DotBotTheme) { $script:theme = Get-DotBotTheme } $t = Get-DotBotTheme # Build CLI args $cliArgs = Build-ProviderCliArgs -Config $config -Prompt $Prompt -ModelId $Model ` -SessionId $SessionId -PersistSession ([bool]$PersistSession) -Streaming $true ` -PermissionMode $PermissionMode $executable = $config.executable # Load the appropriate stream parser $parserName = $config.stream_parser $parserScript = "$PSScriptRoot\parsers\Parse-$($parserName)Stream.ps1" if (-not (Test-Path $parserScript)) { throw "Stream parser not found: $parserScript" } # Initialize parser state $parserState = @{ assistantText = New-Object System.Text.StringBuilder totalInputTokens = 0 totalOutputTokens = 0 totalCacheRead = 0 totalCacheCreate = 0 pendingToolCalls = @() lastUnknown = Get-Date theme = $t } # Dot-source the parser to get Process-StreamLine function . $parserScript # Debug output if ($ShowDebugJson) { [Console]::Error.WriteLine("") [Console]::Error.WriteLine("$($t.Bezel)--- PROVIDER: $($config.display_name) ---$($t.Reset)") [Console]::Error.WriteLine("$($t.Bezel)Executable: $executable$($t.Reset)") [Console]::Error.WriteLine("$($t.Bezel)Args: $($cliArgs -join ' ')$($t.Reset)") [Console]::Error.Flush() } # Ensure UTF-8 $prevOutputEncoding = [Console]::OutputEncoding [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 try { $Prompt | & $executable @cliArgs 2>&1 | ForEach-Object -Process { $raw = $_.ToString() if (-not $raw) { return } $line = $raw.TrimStart() if ($line.Length -eq 0) { return } # Dispatch to parser $result = Process-StreamLine -Line $line -State $parserState -ShowDebugJson:$ShowDebugJson -ShowVerbose:$ShowVerbose if ($result -eq 'rate_limit') { $script:LastProviderRateLimitInfo = $parserState.rateLimitMessage } } } finally { [Console]::OutputEncoding = $prevOutputEncoding } } function Invoke-Provider { <# .SYNOPSIS Simple non-streaming provider invocation. .PARAMETER Prompt The prompt to send. .PARAMETER Model Full model ID (default: provider's default). .PARAMETER ProviderName Override provider name. #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [string]$Prompt, [Parameter(Position = 1)] [string]$Model, [string]$ProviderName, [string]$PermissionMode ) $config = Get-ProviderConfig -Name $ProviderName if (-not $Model) { $Model = $config.models.($config.default_model).id } # For Claude, delegate to Invoke-Claude if ($config.name -eq 'claude') { $permArgs = Resolve-PermissionArgs -Config $config -PermissionMode $PermissionMode return Invoke-Claude -Prompt $Prompt -Model $Model -PermissionArgs $permArgs } # Non-Claude: build args without streaming $cliArgs = Build-ProviderCliArgs -Config $config -Prompt $Prompt -ModelId $Model -Streaming $false -PermissionMode $PermissionMode $executable = $config.executable $previousOutputEncoding = $OutputEncoding $previousConsoleInputEncoding = [Console]::InputEncoding $previousConsoleOutputEncoding = [Console]::OutputEncoding $utf8Encoding = [System.Text.UTF8Encoding]::new($false) try { $OutputEncoding = $utf8Encoding [Console]::InputEncoding = $utf8Encoding [Console]::OutputEncoding = $utf8Encoding $Prompt | & $executable @cliArgs } finally { $OutputEncoding = $previousOutputEncoding [Console]::InputEncoding = $previousConsoleInputEncoding [Console]::OutputEncoding = $previousConsoleOutputEncoding } } function New-ProviderSession { <# .SYNOPSIS Creates a new session ID. Returns GUID for Claude, $null for providers without session support. #> [CmdletBinding()] param( [string]$ProviderName ) $config = Get-ProviderConfig -Name $ProviderName if ($config.capabilities.session_id) { return [Guid]::NewGuid().ToString() } return $null } function Get-LastProviderRateLimitInfo { <# .SYNOPSIS Gets the last rate limit message from the most recent provider stream invocation. #> [CmdletBinding()] param() return $script:LastProviderRateLimitInfo } #endregion Export-ModuleMember -Function @( 'Get-ProviderConfig' 'Get-ProviderModels' 'Resolve-ProviderModelId' 'Build-ProviderCliArgs' 'Invoke-ProviderStream' 'Invoke-Provider' 'New-ProviderSession' 'Get-LastProviderRateLimitInfo' ) |