diff --git a/Public/Invoke-PSClaudeCode.ps1 b/Public/Invoke-PSClaudeCode.ps1 index fa2c454..c4a79fa 100644 --- a/Public/Invoke-PSClaudeCode.ps1 +++ b/Public/Invoke-PSClaudeCode.ps1 @@ -3,9 +3,9 @@ Invokes Claude Code, an AI-powered PowerShell agent that can perform tasks using structured tools. .DESCRIPTION - Invoke-PSClaudeCode uses Anthropic's Claude AI model to execute tasks by leveraging various tools including - file reading/writing, command execution, and sub-agent delegation. The function supports pipeline input - and provides safety checks for potentially dangerous operations. + Invoke-PSClaudeCode uses configurable LLM providers (Anthropic or OpenAI) to execute tasks by leveraging + various tools including file reading/writing, command execution, and sub-agent delegation. The function + supports pipeline input and provides safety checks for potentially dangerous operations. .PARAMETER Task The task description for the AI agent to perform. If not provided and pipeline input exists, the piped content becomes the task. @@ -14,7 +14,10 @@ Accepts pipeline input that can be used as part of the task description. .PARAMETER Model - The Claude model to use. Defaults to "claude-sonnet-4-5-20250929". + The model to use. Defaults to "claude-sonnet-4-5-20250929" for Anthropic. + +.PARAMETER Provider + The LLM provider to use. Defaults to "Anthropic". Use "OpenAI" for OpenAI-compatible models. .PARAMETER dangerouslySkipPermissions Switch to skip permission prompts for potentially dangerous operations. Use with caution. @@ -35,7 +38,7 @@ This example runs a command without permission prompts. .NOTES - Requires ANTHROPIC_API_KEY environment variable to be set. + Requires ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable to be set, depending on Provider. The function includes safety checks for file operations and command execution. #> function Invoke-PSClaudeCode { @@ -46,6 +49,8 @@ function Invoke-PSClaudeCode { [Parameter(ValueFromPipeline = $true)] [object]$InputObject, [string]$Model = "claude-sonnet-4-5-20250929", + [ValidateSet("Anthropic", "OpenAI")] + [string]$Provider = "Anthropic", [switch]$dangerouslySkipPermissions ) @@ -69,10 +74,220 @@ function Invoke-PSClaudeCode { # proceed with the rest of the function using the possibly-updated $Task - $apiKey = $env:ANTHROPIC_API_KEY - if (-not $apiKey) { Write-Host "Set ANTHROPIC_API_KEY"; exit } + function Get-ApiKey { + param([string]$SelectedProvider) + + switch ($SelectedProvider) { + "Anthropic" { return $env:ANTHROPIC_API_KEY } + "OpenAI" { return $env:OPENAI_API_KEY } + default { return $null } + } + } + + function Convert-ToolsForProvider { + param([string]$SelectedProvider, $ToolDefinitions) + + if ($SelectedProvider -eq "OpenAI") { + return $ToolDefinitions | ForEach-Object { + @{ + type = "function" + name = $_.name + description = $_.description + parameters = $_.input_schema + } + } + } + + return $ToolDefinitions + } + + function Normalize-Response { + param([string]$SelectedProvider, $Response) + + if ($SelectedProvider -eq "OpenAI") { + $contentItems = @() + $toolCalls = @() + + foreach ($item in $Response.output) { + if ($item.type -eq "message") { + foreach ($content in $item.content) { + if ($content.type -eq "output_text") { + $contentItems += @{ + type = "text" + text = $content.text + } + } + } + } + if ($item.type -eq "function_call") { + $arguments = $item.arguments + $inputObject = $arguments | ConvertFrom-Json + $contentItems += @{ + type = "tool_use" + id = $item.call_id + name = $item.name + input = $inputObject + } + $toolCalls += @{ + id = $item.call_id + type = "function" + function = @{ + name = $item.name + arguments = $arguments + } + } + } + } + + return @{ + content = $contentItems + openai_output = $Response.output + rawMessage = @{ + content = ($contentItems | Where-Object { $_.type -eq "text" } | ForEach-Object { $_.text }) -join "" + tool_calls = $toolCalls + } + } + } + + return $Response + } + + function Invoke-ModelRequest { + param( + [string]$SelectedProvider, + [string]$SelectedModel, + $MessageHistory, + $ToolDefinitions, + [string]$Key + ) - Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Processing request..." + function Get-ErrorDetails { + param($ErrorRecord) + + $message = $ErrorRecord.Exception.Message + if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { + return "$message`n$($ErrorRecord.ErrorDetails.Message)" + } + + if ($ErrorRecord.Exception.Response) { + try { + $stream = $ErrorRecord.Exception.Response.GetResponseStream() + if ($stream) { + $reader = New-Object System.IO.StreamReader($stream) + $responseBody = $reader.ReadToEnd() + if ($responseBody) { + $message = "$message`n$responseBody" + } + } + } + catch { + return $message + } + } + + return $message + } + + if ($SelectedProvider -eq "OpenAI") { + # Convert MessageHistory to Responses API input format + # Wrap in @() to ensure array even with a single message + [array]$inputItems = @($MessageHistory | ForEach-Object { + $msg = $_ + if ($msg.role -eq "tool") { + # Responses API: tool results are "function_call_output" items + @{ + type = "function_call_output" + call_id = $msg.tool_call_id + output = $msg.tool_output + } + } + elseif ($msg.role -eq "assistant") { + # Re-emit the raw output items the model returned + # so the Responses API sees its own native format + foreach ($item in $msg.openai_output) { + $item + } + } + else { + @{ + type = "message" + role = $msg.role + content = @($msg.content) + } + } + }) + + $body = @{ + model = $SelectedModel + input = $inputItems + max_output_tokens = 4096 + tools = $ToolDefinitions + tool_choice = "auto" + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-RestMethod -Uri "https://api.openai.com/v1/responses" -Method Post -Headers @{ + "Authorization" = "Bearer $Key" + "Content-Type" = "application/json" + } -Body $body -ErrorAction Stop + } + catch { + return @{ error = Get-ErrorDetails -ErrorRecord $_ } + } + + return Normalize-Response -SelectedProvider $SelectedProvider -Response $response + } + + $body = @{ + model = $SelectedModel + messages = $MessageHistory + max_tokens = 4096 + tools = $ToolDefinitions + } | ConvertTo-Json -Depth 10 + + try { + $response = Invoke-RestMethod -Uri "https://api.anthropic.com/v1/messages" -Method Post -Headers @{ + "x-api-key" = $Key + "anthropic-version" = "2023-06-01" + "Content-Type" = "application/json" + } -Body $body -ErrorAction Stop + } + catch { + return @{ error = Get-ErrorDetails -ErrorRecord $_ } + } + + return $response + } + + function Append-ToolResults { + param( + [string]$SelectedProvider, + $MessageHistory, + $ToolResults + ) + + if ($SelectedProvider -eq "OpenAI") { + foreach ($toolResult in $ToolResults) { + $MessageHistory += @{ + role = "tool" + tool_call_id = $toolResult.id + tool_output = $toolResult.content + } + } + return $MessageHistory + } + + $MessageHistory += @{ role = "user"; content = $ToolResults } + return $MessageHistory + } + + $apiKey = Get-ApiKey -SelectedProvider $Provider + if (-not $apiKey) { + Write-Host "Set ANTHROPIC_API_KEY or OPENAI_API_KEY for provider $Provider" + exit + } + + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Processing request with $Provider..." $tools = @( @{ @@ -167,28 +382,32 @@ function Invoke-PSClaudeCode { param([string]$SubTask, [int]$MaxTurns = 10) Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Starting sub-agent for: $SubTask" - $subMessages = @(@{ role = "user"; content = $SubTask }) + $subMessages = @(@{ role = "user"; content = @(@{ type = "text"; text = $SubTask }) }) $turns = 0 + $providerTools = Convert-ToolsForProvider -SelectedProvider $Provider -ToolDefinitions $tools while ($turns -lt $MaxTurns) { $turns++ - Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Consulting Claude..." - $body = @{ - model = $Model - messages = $subMessages - max_tokens = 4096 - tools = $tools - } | ConvertTo-Json -Depth 10 - - $response = Invoke-RestMethod -Uri "https://api.anthropic.com/v1/messages" -Method Post -Headers @{ - "x-api-key" = $apiKey - "anthropic-version" = "2023-06-01" - "Content-Type" = "application/json" - } -Body $body + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Consulting model..." + $response = Invoke-ModelRequest -SelectedProvider $Provider -SelectedModel $Model -MessageHistory $subMessages -ToolDefinitions $providerTools -Key $apiKey + if ($response.error) { + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🚫 $($response.error)" + return "Sub-agent failed: $($response.error)" + } + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Response received, analyzing..." - $assistantMessage = @{ role = "assistant"; content = $response.content } + if ($Provider -eq "OpenAI") { + $assistantMessage = @{ + role = "assistant" + openai_output = $response.openai_output + content = $response.content + } + } + else { + $assistantMessage = @{ role = "assistant"; content = $response.content } + } $subMessages += $assistantMessage $toolUses = $response.content | Where-Object { $_.type -eq "tool_use" } @@ -210,14 +429,17 @@ function Invoke-PSClaudeCode { Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🚫 $result" } - $toolResults += @{ + $toolResult = @{ + content = $result type = "tool_result" tool_use_id = $toolUse.id - content = $result } + if ($Provider -eq "OpenAI") { + $toolResult.id = $toolUse.id + } + $toolResults += $toolResult } - $userMessage = @{ role = "user"; content = $toolResults } - $subMessages += $userMessage + $subMessages = Append-ToolResults -SelectedProvider $Provider -MessageHistory $subMessages -ToolResults $toolResults } else { $textContent = ($response.content | Where-Object { $_.type -eq "text" } | ForEach-Object { $_.text }) -join "" @@ -249,30 +471,35 @@ function Invoke-PSClaudeCode { return $true } - $messages = @(@{ role = "user"; content = $Task }) + $messages = @(@{ role = "user"; content = @(@{ type = "input_text"; text = $Task }) }) + + $providerTools = Convert-ToolsForProvider -SelectedProvider $Provider -ToolDefinitions $tools while ($true) { if ($messages.Count -gt 1) { Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Continuing analysis..." } - Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Consulting Claude..." - $body = @{ - model = $Model - messages = $messages - max_tokens = 4096 - tools = $tools - } | ConvertTo-Json -Depth 10 - - $response = Invoke-RestMethod -Uri "https://api.anthropic.com/v1/messages" -Method Post -Headers @{ - "x-api-key" = $apiKey - "anthropic-version" = "2023-06-01" - "Content-Type" = "application/json" - } -Body $body + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Consulting model..." + $response = Invoke-ModelRequest -SelectedProvider $Provider -SelectedModel $Model -MessageHistory $messages -ToolDefinitions $providerTools -Key $apiKey + if ($response.error) { + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🚫 $($response.error)" + break + } + Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🤖 Response received, analyzing..." - $assistantMessage = @{ role = "assistant"; content = $response.content } + if ($Provider -eq "OpenAI") { + $assistantMessage = @{ + role = "assistant" + openai_output = $response.openai_output + content = $response.content + } + } + else { + $assistantMessage = @{ role = "assistant"; content = $response.content } + } $messages += $assistantMessage $toolUses = $response.content | Where-Object { $_.type -eq "tool_use" } @@ -294,14 +521,17 @@ function Invoke-PSClaudeCode { Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] 🚫 $result" } - $toolResults += @{ + $toolResult = @{ + content = $result type = "tool_result" tool_use_id = $toolUse.id - content = $result } + if ($Provider -eq "OpenAI") { + $toolResult.id = $toolUse.id + } + $toolResults += $toolResult } - $userMessage = @{ role = "user"; content = $toolResults } - $messages += $userMessage + $messages = Append-ToolResults -SelectedProvider $Provider -MessageHistory $messages -ToolResults $toolResults } else { $textContent = ($response.content | Where-Object { $_.type -eq "text" } | ForEach-Object { $_.text }) -join "" @@ -310,4 +540,4 @@ function Invoke-PSClaudeCode { } } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 7fb188a..5632b83 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Ever wondered how AI agents like Claude Code work their magic? Dive into this PowerShell implementation and build your own intelligent assistant from scratch! -Inspired by the original [Claude Code article](https://x.com/dabit3/status/2009668398691582315?s=20), this project demonstrates how to create a PowerShell AI agent using Anthropic's Claude API. Start with a simple command runner and evolve it into a sophisticated agent with function calling, file operations, and conversational capabilities. +Inspired by the original [Claude Code article](https://x.com/dabit3/status/2009668398691582315?s=20), this project demonstrates how to create a PowerShell AI agent using Anthropic or OpenAI-compatible APIs. Start with a simple command runner and evolve it into a sophisticated agent with function calling, file operations, and conversational capabilities. # In Action @@ -65,9 +65,9 @@ The examples above highlight how the agent can handle complex, multi-step tasks - **Agent Loop**: Iterative task completion with AI-driven decision making - **Structured Tools**: Function calling for file operations (read/write) and command execution - **Permission Checks**: Safe operations with user confirmation for dangerous actions -- **Model Selection**: Configurable Claude model selection via parameters +- **Model Selection**: Configurable model selection via parameters - **Sub-agent Support**: Delegates complex tasks to specialized sub-agents -- **PowerShell Native**: Built entirely in PowerShell, compatible with Anthropic Claude API +- **PowerShell Native**: Built entirely in PowerShell, compatible with Anthropic and OpenAI APIs - **Progressive Complexity**: Three agent versions showing evolution from simple to advanced - **Comment-Based Help**: Full PowerShell help documentation with `Get-Help Invoke-PSClaudeCode` - **Pipeline Input Support**: Accept context via pipeline for enhanced task descriptions @@ -75,7 +75,7 @@ The examples above highlight how the agent can handle complex, multi-step tasks ## Prerequisites - PowerShell 5.1 or higher -- Anthropic API key (set as environment variable `$env:ANTHROPIC_API_KEY`) +- Anthropic API key (`$env:ANTHROPIC_API_KEY`) or OpenAI API key (`$env:OPENAI_API_KEY`) ## Installation 1. Clone the repository: @@ -84,9 +84,11 @@ The examples above highlight how the agent can handle complex, multi-step tasks cd PSClaudeCode ``` -2. Set your Anthropic API key: +2. Set your API key: ```powershell $env:ANTHROPIC_API_KEY = "your-api-key-here" + # or + $env:OPENAI_API_KEY = "your-api-key-here" ``` 3. Import the module: @@ -125,7 +127,8 @@ Get-Help Invoke-PSClaudeCode -Parameter Task ### Parameters - **`-Task`**: The task description for the AI agent to complete (required) -- **`-Model`**: The Claude model to use (optional, defaults to "claude-sonnet-4-5-20250929") +- **`-Model`**: The model to use (optional, defaults to "claude-sonnet-4-5-20250929") +- **`-Provider`**: The LLM provider to use (`Anthropic` or `OpenAI`, defaults to `Anthropic`) - **`-dangerouslySkipPermissions`**: Switch to bypass user confirmation prompts for dangerous operations (use with caution) - **Pipeline Input**: Accepts pipeline input as additional context for the task @@ -135,7 +138,7 @@ Get-Help Invoke-PSClaudeCode -Parameter Task Invoke-PSClaudeCode -Task "Create a new file called 'test.txt' with 'Hello, World!'" # Specify a different model -Invoke-PSClaudeCode -Task "List all files in the current directory" -Model "claude-3-5-sonnet-20241022" +Invoke-PSClaudeCode -Task "List all files in the current directory" -Model "claude-3-5-sonnet-20241022" -Provider Anthropic # Bypass permission checks (use with caution) Invoke-PSClaudeCode -Task "Delete all .tmp files in the current directory" -dangerouslySkipPermissions @@ -180,7 +183,10 @@ Get-ChildItem "*.json" | Get-Content | Invoke-PSClaudeCode -Task "Compare these ### Using Different Models ```powershell # Use Claude 3.5 Sonnet -Invoke-PSClaudeCode -Task "Analyze the PowerShell scripts in this directory" -Model "claude-3-5-sonnet-20241022" +Invoke-PSClaudeCode -Task "Analyze the PowerShell scripts in this directory" -Model "claude-3-5-sonnet-20241022" -Provider Anthropic + +# Use OpenAI (Responses API) +Invoke-PSClaudeCode -Task "Summarize the README" -Model "gpt-4.1" -Provider OpenAI # Use the latest Claude Sonnet (default) Invoke-PSClaudeCode -Task "Create a summary of all .md files in the repository" diff --git a/Tests/Invoke-PSClaudeCode.Tests.ps1 b/Tests/Invoke-PSClaudeCode.Tests.ps1 index 05eccaa..0067169 100644 --- a/Tests/Invoke-PSClaudeCode.Tests.ps1 +++ b/Tests/Invoke-PSClaudeCode.Tests.ps1 @@ -3,8 +3,9 @@ BeforeAll { $ModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'PSClaudeCode.psd1' Import-Module $ModulePath -Force - # Store original API key + # Store original API keys $script:OriginalApiKey = $env:ANTHROPIC_API_KEY + $script:OriginalOpenAiKey = $env:OPENAI_API_KEY # Load function content once for all tests $script:FunctionPath = Join-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'Public') 'Invoke-PSClaudeCode.ps1' @@ -12,8 +13,9 @@ BeforeAll { } AfterAll { - # Restore original API key + # Restore original API keys $env:ANTHROPIC_API_KEY = $script:OriginalApiKey + $env:OPENAI_API_KEY = $script:OriginalOpenAiKey } Describe 'Invoke-PSClaudeCode' { @@ -35,6 +37,14 @@ Describe 'Invoke-PSClaudeCode' { $command.Parameters['dangerouslySkipPermissions'].SwitchParameter | Should -Be $true } + It 'Should have a Provider parameter with ValidateSet values' { + $command = Get-Command Invoke-PSClaudeCode + $command.Parameters.Keys | Should -Contain 'Provider' + $validateSet = $command.Parameters['Provider'].Attributes | Where-Object { $_.TypeId.Name -eq 'ValidateSetAttribute' } + $validateSet.ValidValues | Should -Contain 'Anthropic' + $validateSet.ValidValues | Should -Contain 'OpenAI' + } + It 'Should have an InputObject parameter that accepts pipeline input' { $command = Get-Command Invoke-PSClaudeCode $command.Parameters.Keys | Should -Contain 'InputObject' @@ -46,19 +56,22 @@ Describe 'Invoke-PSClaudeCode' { Context 'API Key Validation' { BeforeEach { - # Clear API key for these tests + # Clear API keys for these tests $env:ANTHROPIC_API_KEY = $null + $env:OPENAI_API_KEY = $null } AfterEach { - # Restore API key + # Restore API keys $env:ANTHROPIC_API_KEY = $script:OriginalApiKey + $env:OPENAI_API_KEY = $script:OriginalOpenAiKey } - It 'Should validate API key is set' { + It 'Should validate API keys are set' { # We can't directly test the exit behavior in Pester easily, # but we can verify the function requires the environment variable $env:ANTHROPIC_API_KEY | Should -BeNullOrEmpty + $env:OPENAI_API_KEY | Should -BeNullOrEmpty } } @@ -137,8 +150,9 @@ Describe 'Invoke-PSClaudeCode' { $script:FunctionContent | Should -Match 'MaxTurns.*=.*10' } - It 'Should call Anthropic API in sub-agent' { + It 'Should call supported API endpoints in sub-agent' { $script:FunctionContent | Should -Match 'api\.anthropic\.com/v1/messages' + $script:FunctionContent | Should -Match 'api\.openai\.com/v1/responses' } } @@ -147,19 +161,56 @@ Describe 'Invoke-PSClaudeCode' { $script:FunctionContent | Should -Match 'Model\s*=\s*"claude-sonnet-4-5-20250929"' } - It 'Should call Anthropic API endpoint' { + It 'Should call supported API endpoints' { $script:FunctionContent | Should -Match 'https://api\.anthropic\.com/v1/messages' + $script:FunctionContent | Should -Match 'https://api\.openai\.com/v1/responses' } It 'Should set correct API headers' { $script:FunctionContent | Should -Match 'x-api-key' $script:FunctionContent | Should -Match 'anthropic-version' + $script:FunctionContent | Should -Match 'Authorization' + } + + It 'Should use max_output_tokens for OpenAI requests' { + $script:FunctionContent | Should -Match 'max_output_tokens' + } + + It 'Should format OpenAI input as input_text items' { + $script:FunctionContent | Should -Match 'input_text' + } + + It 'Should format OpenAI assistant input as output_text items' { + $script:FunctionContent | Should -Match 'output_text' + } + + It 'Should format OpenAI tool results as tool_result items' { + $script:FunctionContent | Should -Match 'tool_result' } It 'Should process tool uses from API response' { $script:FunctionContent | Should -Match 'tool_use' } + It 'Should handle OpenAI tool calls in responses' { + $script:FunctionContent | Should -Match 'tool_calls' + } + + It 'Should format OpenAI tools with function schema' { + $script:FunctionContent | Should -Match 'type\\s*=\\s*"function"' + $script:FunctionContent | Should -Match 'name\\s*=\\s*\\$_\\.name' + $script:FunctionContent | Should -Match 'description\\s*=\\s*\\$_\\.description' + $script:FunctionContent | Should -Match 'parameters\\s*=\\s*\\$_\\.input_schema' + } + + It 'Should extract API error details from response stream' { + $script:FunctionContent | Should -Match 'GetResponseStream' + } + + It 'Should use ErrorDetails when present for API errors' { + $script:FunctionContent | Should -Match 'ErrorDetails' + } + It 'Should handle text content in responses' { $script:FunctionContent | Should -Match 'type.*-eq.*"text"' } @@ -218,13 +269,13 @@ Describe 'Invoke-PSClaudeCode' { It 'Should have DESCRIPTION section' { $help = Get-Help Invoke-PSClaudeCode $help.Description | Should -Not -BeNullOrEmpty - $help.Description.Text | Should -Match 'Anthropic.*Claude' + $help.Description.Text | Should -Match 'Anthropic|OpenAI' } It 'Should have PARAMETERS section' { $help = Get-Help Invoke-PSClaudeCode $help.Parameters | Should -Not -BeNullOrEmpty - $help.Parameters.Parameter | Should -HaveCount 4 # Task, InputObject, Model, dangerouslySkipPermissions + $help.Parameters.Parameter | Should -HaveCount 5 # Task, InputObject, Model, Provider, dangerouslySkipPermissions } It 'Should have parameter help for Task' { @@ -238,7 +289,14 @@ Describe 'Invoke-PSClaudeCode' { $help = Get-Help Invoke-PSClaudeCode $modelParam = $help.Parameters.Parameter | Where-Object { $_.Name -eq 'Model' } $modelParam | Should -Not -BeNullOrEmpty - $modelParam.Description.Text | Should -Match 'Claude model' + $modelParam.Description.Text | Should -Match 'model' + } + + It 'Should have parameter help for Provider' { + $help = Get-Help Invoke-PSClaudeCode + $providerParam = $help.Parameters.Parameter | Where-Object { $_.Name -eq 'Provider' } + $providerParam | Should -Not -BeNullOrEmpty + $providerParam.Description.Text | Should -Match 'provider' } It 'Should have parameter help for dangerouslySkipPermissions' { @@ -257,7 +315,7 @@ Describe 'Invoke-PSClaudeCode' { It 'Should have NOTES section' { $help = Get-Help Invoke-PSClaudeCode $help.AlertSet | Should -Not -BeNullOrEmpty - $help.AlertSet.Alert.Text | Should -Match 'ANTHROPIC_API_KEY' + $help.AlertSet.Alert.Text | Should -Match 'ANTHROPIC_API_KEY|OPENAI_API_KEY' } } }